Merge pull request #795 from Fr1ngg/bedolaga-4ou2e3
Display promo offer details on main menu
This commit is contained in:
+34
-1
@@ -17,6 +17,10 @@ from app.services.subscription_checkout_service import (
|
||||
)
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
build_test_access_hint,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -300,9 +304,38 @@ async def get_main_menu_text(user, texts, db: AsyncSession):
|
||||
user_name=user.full_name,
|
||||
subscription_status=_get_subscription_status(user, texts)
|
||||
)
|
||||
|
||||
|
||||
action_prompt = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:")
|
||||
|
||||
info_sections: list[str] = []
|
||||
|
||||
try:
|
||||
promo_hint = await build_promo_offer_hint(db, user, texts)
|
||||
if promo_hint:
|
||||
info_sections.append(promo_hint.strip())
|
||||
except Exception as hint_error:
|
||||
logger.debug(
|
||||
"Не удалось построить подсказку промо-предложения для пользователя %s: %s",
|
||||
getattr(user, "id", None),
|
||||
hint_error,
|
||||
)
|
||||
|
||||
try:
|
||||
test_access_hint = await build_test_access_hint(db, user, texts)
|
||||
if test_access_hint:
|
||||
info_sections.append(test_access_hint.strip())
|
||||
except Exception as test_error:
|
||||
logger.debug(
|
||||
"Не удалось построить подсказку тестового доступа для пользователя %s: %s",
|
||||
getattr(user, "id", None),
|
||||
test_error,
|
||||
)
|
||||
|
||||
if info_sections:
|
||||
extra_block = "\n\n".join(section for section in info_sections if section)
|
||||
if extra_block:
|
||||
base_text = _insert_random_message(base_text, extra_block, action_prompt)
|
||||
|
||||
try:
|
||||
random_message = await get_random_active_message(db)
|
||||
if random_message:
|
||||
|
||||
@@ -32,6 +32,10 @@ from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
build_test_access_hint,
|
||||
)
|
||||
from app.database.crud.user_message import get_random_active_message
|
||||
|
||||
|
||||
@@ -1279,6 +1283,35 @@ async def get_main_menu_text(user, texts, db: AsyncSession):
|
||||
|
||||
action_prompt = texts.t("MAIN_MENU_ACTION_PROMPT", "Выберите действие:")
|
||||
|
||||
info_sections: list[str] = []
|
||||
|
||||
try:
|
||||
promo_hint = await build_promo_offer_hint(db, user, texts)
|
||||
if promo_hint:
|
||||
info_sections.append(promo_hint.strip())
|
||||
except Exception as hint_error:
|
||||
logger.debug(
|
||||
"Не удалось построить подсказку промо-предложения для пользователя %s: %s",
|
||||
getattr(user, "id", None),
|
||||
hint_error,
|
||||
)
|
||||
|
||||
try:
|
||||
test_access_hint = await build_test_access_hint(db, user, texts)
|
||||
if test_access_hint:
|
||||
info_sections.append(test_access_hint.strip())
|
||||
except Exception as test_error:
|
||||
logger.debug(
|
||||
"Не удалось построить подсказку тестового доступа для пользователя %s: %s",
|
||||
getattr(user, "id", None),
|
||||
test_error,
|
||||
)
|
||||
|
||||
if info_sections:
|
||||
extra_block = "\n\n".join(section for section in info_sections if section)
|
||||
if extra_block:
|
||||
base_text = _insert_random_message(base_text, extra_block, action_prompt)
|
||||
|
||||
try:
|
||||
random_message = await get_random_active_message(db)
|
||||
if random_message:
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Any, Tuple, Optional
|
||||
|
||||
@@ -12,7 +11,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings, PERIOD_PRICES, get_traffic_prices
|
||||
from app.database.crud.discount_offer import (
|
||||
get_offer_by_id,
|
||||
get_latest_claimed_offer_for_user,
|
||||
mark_offer_claimed,
|
||||
)
|
||||
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
|
||||
@@ -74,6 +72,10 @@ from app.utils.subscription_utils import (
|
||||
get_happ_cryptolink_redirect_link,
|
||||
convert_subscription_link_to_happ_scheme,
|
||||
)
|
||||
from app.utils.promo_offer import (
|
||||
build_promo_offer_hint,
|
||||
get_user_active_promo_discount_percent,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -118,19 +120,7 @@ def _apply_addon_discount(
|
||||
|
||||
|
||||
def _get_promo_offer_discount_percent(user: Optional[User]) -> int:
|
||||
if not user:
|
||||
return 0
|
||||
|
||||
try:
|
||||
percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
expires_at = getattr(user, "promo_offer_discount_expires_at", None)
|
||||
if expires_at and expires_at <= datetime.utcnow():
|
||||
return 0
|
||||
|
||||
return max(0, min(100, percent))
|
||||
return get_user_active_promo_discount_percent(user)
|
||||
|
||||
|
||||
def _apply_promo_offer_discount(user: Optional[User], amount: int) -> Dict[str, int]:
|
||||
@@ -143,114 +133,13 @@ def _apply_promo_offer_discount(user: Optional[User], amount: int) -> Dict[str,
|
||||
return {"discounted": discounted, "discount": discount_value, "percent": percent}
|
||||
|
||||
|
||||
def _format_promo_offer_time_left(seconds_left: int, language: str) -> str:
|
||||
total_minutes = max(1, math.ceil(seconds_left / 60))
|
||||
days, remainder_minutes = divmod(total_minutes, 60 * 24)
|
||||
hours, minutes = divmod(remainder_minutes, 60)
|
||||
|
||||
language_code = (language or "ru").split("-")[0].lower()
|
||||
if language_code == "en":
|
||||
day_label, hour_label, minute_label = "d", "h", "m"
|
||||
else:
|
||||
day_label, hour_label, minute_label = "д", "ч", "м"
|
||||
|
||||
parts: List[str] = []
|
||||
if days:
|
||||
parts.append(f"{days}{day_label}")
|
||||
if hours or days:
|
||||
parts.append(f"{hours}{hour_label}")
|
||||
parts.append(f"{minutes}{minute_label}")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
async def _get_promo_offer_timer_line(
|
||||
db: AsyncSession,
|
||||
db_user: User,
|
||||
texts,
|
||||
) -> Optional[str]:
|
||||
expires_at = getattr(db_user, "promo_offer_discount_expires_at", None)
|
||||
if not expires_at:
|
||||
return None
|
||||
|
||||
now = datetime.utcnow()
|
||||
if expires_at <= now:
|
||||
return None
|
||||
|
||||
seconds_left = int((expires_at - now).total_seconds())
|
||||
if seconds_left <= 0:
|
||||
return None
|
||||
|
||||
total_seconds: Optional[int] = None
|
||||
source = getattr(db_user, "promo_offer_discount_source", None)
|
||||
|
||||
try:
|
||||
offer = await get_latest_claimed_offer_for_user(db, db_user.id, source)
|
||||
except Exception as lookup_error: # pragma: no cover - defensive logging
|
||||
logger.debug(
|
||||
"Failed to resolve latest claimed promo offer for user %s: %s",
|
||||
db_user.id,
|
||||
lookup_error,
|
||||
)
|
||||
offer = None
|
||||
|
||||
if offer and getattr(offer, "claimed_at", None):
|
||||
total_seconds = int((expires_at - offer.claimed_at).total_seconds())
|
||||
if total_seconds <= 0:
|
||||
total_seconds = None
|
||||
|
||||
if total_seconds is None and offer and isinstance(offer.extra_data, dict):
|
||||
raw_duration = (
|
||||
offer.extra_data.get("active_discount_hours")
|
||||
or offer.extra_data.get("duration_hours")
|
||||
)
|
||||
try:
|
||||
if raw_duration:
|
||||
total_seconds = int(float(raw_duration) * 3600)
|
||||
except (TypeError, ValueError):
|
||||
total_seconds = None
|
||||
|
||||
if total_seconds is None or total_seconds <= 0:
|
||||
total_seconds = seconds_left
|
||||
|
||||
ratio = max(0.0, min(1.0, seconds_left / total_seconds))
|
||||
bar_length = 10
|
||||
filled_segments = int(round(ratio * bar_length))
|
||||
filled_segments = max(0, min(bar_length, filled_segments))
|
||||
if filled_segments == 0 and seconds_left > 0:
|
||||
filled_segments = 1
|
||||
|
||||
bar = f"[{'█' * filled_segments}{'░' * (bar_length - filled_segments)}]"
|
||||
time_left_text = _format_promo_offer_time_left(seconds_left, getattr(texts, "language", "ru"))
|
||||
|
||||
template = texts.t(
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER",
|
||||
"⏳ Discount active for {time_left}\n<code>{bar}</code>",
|
||||
)
|
||||
return template.format(bar=bar, time_left=time_left_text)
|
||||
|
||||
|
||||
async def _get_promo_offer_hint(
|
||||
db: AsyncSession,
|
||||
db_user: User,
|
||||
texts,
|
||||
percent: Optional[int] = None,
|
||||
) -> Optional[str]:
|
||||
if percent is None:
|
||||
percent = _get_promo_offer_discount_percent(db_user)
|
||||
|
||||
if percent <= 0:
|
||||
return None
|
||||
|
||||
base_hint = texts.t(
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_HINT",
|
||||
"⚡ Extra {percent}% discount is active and will apply automatically. It stacks with other discounts.",
|
||||
).format(percent=percent)
|
||||
|
||||
timer_line = await _get_promo_offer_timer_line(db, db_user, texts)
|
||||
if timer_line:
|
||||
return f"{base_hint}\n{timer_line}"
|
||||
|
||||
return base_hint
|
||||
return await build_promo_offer_hint(db, db_user, texts, percent)
|
||||
|
||||
|
||||
def _get_period_hint_from_subscription(subscription: Optional[Subscription]) -> Optional[int]:
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
"LOADING": "⏳ Loading...",
|
||||
"MAIN_MENU": "👤 <b>{user_name}</b>\n\n📱 <b>Subscription:</b> {subscription_status}\n\nChoose an option:\n",
|
||||
"MAIN_MENU_ACTION_PROMPT": "Choose an option:",
|
||||
"MAIN_MENU_TEST_ACCESS_HEADER": "🧪 Test servers active: {count}",
|
||||
"MAIN_MENU_TEST_ACCESS_TIMER": "⏳ Access active for {time_left}\n<code>{bar}</code>",
|
||||
"MAIN_MENU_BUTTON": "🏠 Main menu",
|
||||
"MANAGE_DEVICES_BUTTON": "🔧 Manage devices",
|
||||
"DEVICE_UUID_NOT_FOUND": "❌ User UUID not found",
|
||||
|
||||
@@ -202,6 +202,8 @@
|
||||
"MAINTENANCE_MODE_API_ERROR": "\n🔧 Технические работы!\n\nСервис временно недоступен из-за проблем с подключением к серверам.\n\n⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.\n\n🔄 Последняя проверка: {last_check}\n",
|
||||
"MAIN_MENU": "👤 <b>{user_name}</b>\n \n📱 <b>Подписка:</b> {subscription_status}\n\nВыберите действие:\n",
|
||||
"MAIN_MENU_ACTION_PROMPT": "Выберите действие:",
|
||||
"MAIN_MENU_TEST_ACCESS_HEADER": "🧪 Тестовые сервера активны: {count}",
|
||||
"MAIN_MENU_TEST_ACCESS_TIMER": "⏳ Доступ действует ещё: {time_left}\n<code>{bar}</code>",
|
||||
"MAIN_MENU_BUTTON": "🏠 Главное меню",
|
||||
"MANAGE_DEVICES_BUTTON": "🔧 Управление устройствами",
|
||||
"DEVICE_UUID_NOT_FOUND": "❌ UUID пользователя не найден",
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.crud.discount_offer import get_latest_claimed_offer_for_user
|
||||
from app.database.models import SubscriptionTemporaryAccess, User
|
||||
|
||||
|
||||
def get_user_active_promo_discount_percent(user: Optional[User]) -> int:
|
||||
if not user:
|
||||
return 0
|
||||
|
||||
try:
|
||||
percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
expires_at = getattr(user, "promo_offer_discount_expires_at", None)
|
||||
if expires_at and expires_at <= datetime.utcnow():
|
||||
return 0
|
||||
|
||||
return max(0, min(100, percent))
|
||||
|
||||
|
||||
def _format_time_left(seconds_left: int, language: str) -> str:
|
||||
total_minutes = max(1, math.ceil(seconds_left / 60))
|
||||
days, remainder_minutes = divmod(total_minutes, 60 * 24)
|
||||
hours, minutes = divmod(remainder_minutes, 60)
|
||||
|
||||
language_code = (language or "ru").split("-")[0].lower()
|
||||
if language_code == "en":
|
||||
day_label, hour_label, minute_label = "d", "h", "m"
|
||||
else:
|
||||
day_label, hour_label, minute_label = "д", "ч", "м"
|
||||
|
||||
parts: list[str] = []
|
||||
if days:
|
||||
parts.append(f"{days}{day_label}")
|
||||
if hours or days:
|
||||
parts.append(f"{hours}{hour_label}")
|
||||
parts.append(f"{minutes}{minute_label}")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _build_progress_bar(seconds_left: int, total_seconds: int) -> str:
|
||||
if total_seconds <= 0:
|
||||
total_seconds = seconds_left or 1
|
||||
|
||||
ratio = max(0.0, min(1.0, seconds_left / total_seconds))
|
||||
bar_length = 10
|
||||
filled_segments = int(round(ratio * bar_length))
|
||||
filled_segments = max(0, min(bar_length, filled_segments))
|
||||
if filled_segments == 0 and seconds_left > 0:
|
||||
filled_segments = 1
|
||||
|
||||
return f"[{'█' * filled_segments}{'░' * (bar_length - filled_segments)}]"
|
||||
|
||||
|
||||
async def build_promo_offer_timer_line(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
texts,
|
||||
) -> Optional[str]:
|
||||
expires_at = getattr(user, "promo_offer_discount_expires_at", None)
|
||||
if not expires_at:
|
||||
return None
|
||||
|
||||
now = datetime.utcnow()
|
||||
if expires_at <= now:
|
||||
return None
|
||||
|
||||
seconds_left = int((expires_at - now).total_seconds())
|
||||
if seconds_left <= 0:
|
||||
return None
|
||||
|
||||
total_seconds: Optional[int] = None
|
||||
source = getattr(user, "promo_offer_discount_source", None)
|
||||
|
||||
try:
|
||||
offer = await get_latest_claimed_offer_for_user(db, user.id, source)
|
||||
except Exception:
|
||||
offer = None
|
||||
|
||||
if offer and getattr(offer, "claimed_at", None):
|
||||
total_seconds = int((expires_at - offer.claimed_at).total_seconds())
|
||||
if total_seconds <= 0:
|
||||
total_seconds = None
|
||||
|
||||
if total_seconds is None and offer:
|
||||
extra_data = getattr(offer, "extra_data", None)
|
||||
if isinstance(extra_data, dict):
|
||||
raw_duration = (
|
||||
extra_data.get("active_discount_hours")
|
||||
or extra_data.get("duration_hours")
|
||||
)
|
||||
else:
|
||||
raw_duration = None
|
||||
try:
|
||||
if raw_duration:
|
||||
total_seconds = int(float(raw_duration) * 3600)
|
||||
except (TypeError, ValueError):
|
||||
total_seconds = None
|
||||
|
||||
if total_seconds is None or total_seconds <= 0:
|
||||
total_seconds = seconds_left
|
||||
|
||||
bar = _build_progress_bar(seconds_left, total_seconds)
|
||||
time_left_text = _format_time_left(seconds_left, getattr(texts, "language", "ru"))
|
||||
|
||||
template = texts.t(
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER",
|
||||
"⏳ Discount active for {time_left}\n<code>{bar}</code>",
|
||||
)
|
||||
return template.format(bar=bar, time_left=time_left_text)
|
||||
|
||||
|
||||
async def build_promo_offer_hint(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
texts,
|
||||
percent: Optional[int] = None,
|
||||
) -> Optional[str]:
|
||||
if percent is None:
|
||||
percent = get_user_active_promo_discount_percent(user)
|
||||
|
||||
if percent <= 0:
|
||||
return None
|
||||
|
||||
base_hint = texts.t(
|
||||
"SUBSCRIPTION_PROMO_DISCOUNT_HINT",
|
||||
"⚡ Extra {percent}% discount is active and will apply automatically. It stacks with other discounts.",
|
||||
).format(percent=percent)
|
||||
|
||||
timer_line = await build_promo_offer_timer_line(db, user, texts)
|
||||
if timer_line:
|
||||
return f"{base_hint}\n{timer_line}"
|
||||
|
||||
return base_hint
|
||||
|
||||
|
||||
async def build_test_access_hint(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
texts,
|
||||
) -> Optional[str]:
|
||||
subscription = getattr(user, "subscription", None)
|
||||
if not subscription:
|
||||
return None
|
||||
|
||||
subscription_id = getattr(subscription, "id", None)
|
||||
if not subscription_id:
|
||||
return None
|
||||
|
||||
now = datetime.utcnow()
|
||||
|
||||
result = await db.execute(
|
||||
select(SubscriptionTemporaryAccess)
|
||||
.options(selectinload(SubscriptionTemporaryAccess.offer))
|
||||
.where(
|
||||
SubscriptionTemporaryAccess.subscription_id == subscription_id,
|
||||
SubscriptionTemporaryAccess.is_active == True, # noqa: E712
|
||||
SubscriptionTemporaryAccess.expires_at > now,
|
||||
)
|
||||
.order_by(SubscriptionTemporaryAccess.expires_at.desc())
|
||||
)
|
||||
entries: Sequence[SubscriptionTemporaryAccess] = result.scalars().all()
|
||||
|
||||
active_entries = [
|
||||
entry for entry in entries if entry.expires_at and entry.expires_at > now
|
||||
]
|
||||
if not active_entries:
|
||||
return None
|
||||
|
||||
latest_expiry = max(entry.expires_at for entry in active_entries)
|
||||
seconds_left = int((latest_expiry - now).total_seconds())
|
||||
if seconds_left <= 0:
|
||||
return None
|
||||
|
||||
total_seconds: Optional[int] = None
|
||||
for entry in active_entries:
|
||||
offer = entry.offer
|
||||
claimed_at = getattr(offer, "claimed_at", None) if offer else None
|
||||
if claimed_at:
|
||||
total = int((entry.expires_at - claimed_at).total_seconds())
|
||||
if total > 0 and (total_seconds is None or total > total_seconds):
|
||||
total_seconds = total
|
||||
|
||||
if total_seconds is None or total_seconds <= 0:
|
||||
total_seconds = seconds_left
|
||||
|
||||
bar = _build_progress_bar(seconds_left, total_seconds)
|
||||
time_left_text = _format_time_left(seconds_left, getattr(texts, "language", "ru"))
|
||||
|
||||
unique_squads = {
|
||||
entry.squad_uuid for entry in active_entries if getattr(entry, "squad_uuid", None)
|
||||
}
|
||||
count = len(unique_squads) or len(active_entries)
|
||||
|
||||
header_template = texts.t(
|
||||
"MAIN_MENU_TEST_ACCESS_HEADER",
|
||||
"🧪 Test servers active: {count}",
|
||||
)
|
||||
timer_template = texts.t(
|
||||
"MAIN_MENU_TEST_ACCESS_TIMER",
|
||||
"⏳ Access active for {time_left}\n<code>{bar}</code>",
|
||||
)
|
||||
|
||||
header = header_template.format(count=count)
|
||||
timer_line = timer_template.format(time_left=time_left_text, bar=bar)
|
||||
|
||||
return f"{header}\n{timer_line}"
|
||||
@@ -55,6 +55,8 @@
|
||||
"LOADING": "⏳ Loading...",
|
||||
"MAIN_MENU": "👤 <b>{user_name}</b>\n\n📱 <b>Subscription:</b> {subscription_status}\n\nChoose an option:\n",
|
||||
"MAIN_MENU_ACTION_PROMPT": "Choose an option:",
|
||||
"MAIN_MENU_TEST_ACCESS_HEADER": "🧪 Test servers active: {count}",
|
||||
"MAIN_MENU_TEST_ACCESS_TIMER": "⏳ Access active for {time_left}\n<code>{bar}</code>",
|
||||
"MAIN_MENU_BUTTON": "🏠 Main menu",
|
||||
"MANAGE_DEVICES_BUTTON": "🔧 Manage devices",
|
||||
"MENU_BALANCE": "💰 Balance",
|
||||
|
||||
@@ -235,6 +235,8 @@
|
||||
"MAINTENANCE_MODE_API_ERROR": "\n🔧 Технические работы!\n\nСервис временно недоступен из-за проблем с подключением к серверам.\n\n⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.\n\n🔄 Последняя проверка: {last_check}\n",
|
||||
"MAIN_MENU": "👤 <b>{user_name}</b>\n \n📱 <b>Подписка:</b> {subscription_status}\n\nВыберите действие:\n",
|
||||
"MAIN_MENU_ACTION_PROMPT": "Выберите действие:",
|
||||
"MAIN_MENU_TEST_ACCESS_HEADER": "🧪 Тестовые сервера активны: {count}",
|
||||
"MAIN_MENU_TEST_ACCESS_TIMER": "⏳ Доступ действует ещё: {time_left}\n<code>{bar}</code>",
|
||||
"MAIN_MENU_BUTTON": "🏠 Главное меню",
|
||||
"MANAGE_DEVICES_BUTTON": "🔧 Управление устройствами",
|
||||
"MENU_ADMIN": "⚙️ Админ-панель",
|
||||
|
||||
Reference in New Issue
Block a user