9a2aea038a
- Add pyproject.toml with uv and ruff configuration - Pin Python version to 3.13 via .python-version - Add Makefile commands: lint, format, fix - Apply ruff formatting to entire codebase - Remove unused imports (base64 in yookassa/simple_subscription) - Update .gitignore for new config files
440 lines
13 KiB
Python
440 lines
13 KiB
Python
import base64
|
|
import json
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import Any
|
|
from urllib.parse import quote
|
|
|
|
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
|
|
|
from app.config import get_traffic_prices, settings
|
|
from app.database.models import Subscription, User
|
|
from app.localization.texts import get_texts
|
|
from app.utils.pricing_utils import (
|
|
apply_percentage_discount,
|
|
get_remaining_months,
|
|
)
|
|
from app.utils.promo_offer import (
|
|
get_user_active_promo_discount_percent,
|
|
)
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
TRAFFIC_PRICES = get_traffic_prices()
|
|
|
|
|
|
class _SafeFormatDict(dict):
|
|
def __missing__(self, key: str) -> str: # pragma: no cover - defensive fallback
|
|
return '{' + key + '}'
|
|
|
|
|
|
def _format_text_with_placeholders(template: str, values: dict[str, Any]) -> str:
|
|
if not isinstance(template, str):
|
|
return template
|
|
|
|
safe_values = _SafeFormatDict()
|
|
safe_values.update(values)
|
|
|
|
try:
|
|
return template.format_map(safe_values)
|
|
except Exception: # pragma: no cover - defensive logging
|
|
logger.warning("Failed to format template '%s' with values %s", template, values)
|
|
return template
|
|
|
|
|
|
def _get_addon_discount_percent_for_user(
|
|
user: User | None,
|
|
category: str,
|
|
period_days_hint: int | None = None,
|
|
) -> int:
|
|
if user is None:
|
|
return 0
|
|
|
|
promo_group = user.get_primary_promo_group()
|
|
if promo_group is None:
|
|
return 0
|
|
|
|
if not getattr(promo_group, 'apply_discounts_to_addons', True):
|
|
return 0
|
|
|
|
try:
|
|
return user.get_promo_discount(category, period_days_hint)
|
|
except AttributeError:
|
|
return 0
|
|
|
|
|
|
def _apply_addon_discount(
|
|
user: User | None,
|
|
category: str,
|
|
amount: int,
|
|
period_days_hint: int | None = None,
|
|
) -> dict[str, int]:
|
|
percent = _get_addon_discount_percent_for_user(user, category, period_days_hint)
|
|
discounted_amount, discount_value = apply_percentage_discount(amount, percent)
|
|
|
|
return {
|
|
'discounted': discounted_amount,
|
|
'discount': discount_value,
|
|
'percent': percent,
|
|
}
|
|
|
|
|
|
def _get_promo_offer_discount_percent(user: User | None) -> int:
|
|
return get_user_active_promo_discount_percent(user)
|
|
|
|
|
|
def _apply_promo_offer_discount(user: User | None, amount: int) -> dict[str, int]:
|
|
percent = _get_promo_offer_discount_percent(user)
|
|
|
|
if amount <= 0 or percent <= 0:
|
|
return {'discounted': amount, 'discount': 0, 'percent': 0}
|
|
|
|
discounted, discount_value = apply_percentage_discount(amount, percent)
|
|
return {'discounted': discounted, 'discount': discount_value, 'percent': percent}
|
|
|
|
|
|
def _get_period_hint_from_subscription(subscription: Subscription | None) -> int | None:
|
|
if not subscription:
|
|
return None
|
|
|
|
months_remaining = get_remaining_months(subscription.end_date)
|
|
if months_remaining <= 0:
|
|
return None
|
|
|
|
return months_remaining * 30
|
|
|
|
|
|
def _apply_discount_to_monthly_component(
|
|
amount_per_month: int,
|
|
percent: int,
|
|
months: int,
|
|
) -> dict[str, int]:
|
|
discounted_per_month, discount_per_month = apply_percentage_discount(amount_per_month, percent)
|
|
|
|
return {
|
|
'original_per_month': amount_per_month,
|
|
'discounted_per_month': discounted_per_month,
|
|
'discount_percent': max(0, min(100, percent)),
|
|
'discount_per_month': discount_per_month,
|
|
'total': discounted_per_month * months,
|
|
'discount_total': discount_per_month * months,
|
|
}
|
|
|
|
|
|
def update_traffic_prices():
|
|
from app.config import refresh_traffic_prices
|
|
|
|
refresh_traffic_prices()
|
|
logger.info('🔄 TRAFFIC_PRICES обновлены из конфигурации')
|
|
|
|
|
|
def format_traffic_display(traffic_gb: int, is_fixed_mode: bool = None) -> str:
|
|
if is_fixed_mode is None:
|
|
is_fixed_mode = settings.is_traffic_fixed()
|
|
|
|
if traffic_gb == 0:
|
|
if is_fixed_mode:
|
|
return 'Безлимитный'
|
|
return 'Безлимитный'
|
|
if is_fixed_mode:
|
|
return f'{traffic_gb} ГБ'
|
|
return f'{traffic_gb} ГБ'
|
|
|
|
|
|
def validate_traffic_price(gb: int) -> bool:
|
|
from app.config import settings
|
|
|
|
price = settings.get_traffic_price(gb)
|
|
if gb == 0:
|
|
return True
|
|
|
|
return price > 0
|
|
|
|
|
|
def load_app_config() -> dict[str, Any]:
|
|
try:
|
|
from app.config import settings
|
|
|
|
config_path = settings.get_app_config_path()
|
|
|
|
with open(config_path, encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
if isinstance(data, dict):
|
|
return data
|
|
logger.error('Некорректный формат app-config.json: ожидается объект')
|
|
except Exception as e:
|
|
logger.error(f'Ошибка загрузки конфига приложений: {e}')
|
|
|
|
return {}
|
|
|
|
|
|
def get_localized_value(values: Any, language: str, default_language: str = 'en') -> str:
|
|
if not isinstance(values, dict):
|
|
return ''
|
|
|
|
candidates: list[str] = []
|
|
normalized_language = (language or '').strip().lower()
|
|
|
|
if normalized_language:
|
|
candidates.append(normalized_language)
|
|
if '-' in normalized_language:
|
|
candidates.append(normalized_language.split('-')[0])
|
|
|
|
default_language = (default_language or '').strip().lower()
|
|
if default_language and default_language not in candidates:
|
|
candidates.append(default_language)
|
|
|
|
for candidate in candidates:
|
|
if not candidate:
|
|
continue
|
|
value = values.get(candidate)
|
|
if isinstance(value, str) and value.strip():
|
|
return value
|
|
|
|
for value in values.values():
|
|
if isinstance(value, str) and value.strip():
|
|
return value
|
|
|
|
return ''
|
|
|
|
|
|
def get_step_description(app: dict[str, Any], step_key: str, language: str) -> str:
|
|
if not isinstance(app, dict):
|
|
return ''
|
|
|
|
step = app.get(step_key)
|
|
if not isinstance(step, dict):
|
|
return ''
|
|
|
|
description = step.get('description')
|
|
return get_localized_value(description, language)
|
|
|
|
|
|
def format_additional_section(additional: Any, texts, language: str) -> str:
|
|
if not isinstance(additional, dict):
|
|
return ''
|
|
|
|
title = get_localized_value(additional.get('title'), language)
|
|
description = get_localized_value(additional.get('description'), language)
|
|
|
|
parts: list[str] = []
|
|
|
|
if title:
|
|
parts.append(
|
|
texts.t(
|
|
'SUBSCRIPTION_ADDITIONAL_STEP_TITLE',
|
|
'<b>{title}:</b>',
|
|
).format(title=title)
|
|
)
|
|
|
|
if description:
|
|
parts.append(description)
|
|
|
|
return '\n'.join(parts)
|
|
|
|
|
|
def build_redirect_link(target_link: str | None, template: str | None) -> str | None:
|
|
if not target_link or not template:
|
|
return None
|
|
|
|
normalized_target = str(target_link).strip()
|
|
normalized_template = str(template).strip()
|
|
|
|
if not normalized_target or not normalized_template:
|
|
return None
|
|
|
|
encoded_target = quote(normalized_target, safe='')
|
|
result = normalized_template
|
|
replaced = False
|
|
|
|
replacements = [
|
|
('{subscription_link}', encoded_target),
|
|
('{link}', encoded_target),
|
|
('{subscription_link_raw}', normalized_target),
|
|
('{link_raw}', normalized_target),
|
|
]
|
|
|
|
for placeholder, replacement in replacements:
|
|
if placeholder in result:
|
|
result = result.replace(placeholder, replacement)
|
|
replaced = True
|
|
|
|
if not replaced:
|
|
result = f'{result}{encoded_target}'
|
|
|
|
return result
|
|
|
|
|
|
def get_apps_for_device(device_type: str, language: str = 'ru') -> list[dict[str, Any]]:
|
|
config = load_app_config()
|
|
platforms = config.get('platforms', {}) if isinstance(config, dict) else {}
|
|
|
|
if not isinstance(platforms, dict):
|
|
return []
|
|
|
|
device_mapping = {
|
|
'ios': 'ios',
|
|
'android': 'android',
|
|
'windows': 'windows',
|
|
'mac': 'macos',
|
|
'tv': 'androidTV',
|
|
'appletv': 'appleTV',
|
|
'apple_tv': 'appleTV',
|
|
}
|
|
|
|
config_key = device_mapping.get(device_type, device_type)
|
|
apps = platforms.get(config_key, [])
|
|
return apps if isinstance(apps, list) else []
|
|
|
|
|
|
def get_device_name(device_type: str, language: str = 'ru') -> str:
|
|
names = {
|
|
'ios': 'iPhone/iPad',
|
|
'android': 'Android',
|
|
'windows': 'Windows',
|
|
'mac': 'macOS',
|
|
'tv': 'Android TV',
|
|
'appletv': 'Apple TV',
|
|
'apple_tv': 'Apple TV',
|
|
}
|
|
|
|
return names.get(device_type, device_type)
|
|
|
|
|
|
def create_deep_link(app: dict[str, Any], subscription_url: str) -> str | None:
|
|
if not subscription_url:
|
|
return None
|
|
|
|
if not isinstance(app, dict):
|
|
return subscription_url
|
|
|
|
scheme = str(app.get('urlScheme', '')).strip()
|
|
payload = subscription_url
|
|
|
|
if app.get('isNeedBase64Encoding'):
|
|
try:
|
|
payload = base64.b64encode(subscription_url.encode('utf-8')).decode('utf-8')
|
|
except Exception as exc:
|
|
logger.warning(
|
|
'Не удалось закодировать ссылку подписки в base64 для приложения %s: %s',
|
|
app.get('id'),
|
|
exc,
|
|
)
|
|
payload = subscription_url
|
|
|
|
scheme_link = f'{scheme}{payload}' if scheme else None
|
|
|
|
template = settings.get_happ_cryptolink_redirect_template()
|
|
redirect_link = build_redirect_link(scheme_link, template) if scheme_link and template else None
|
|
|
|
return redirect_link or scheme_link or subscription_url
|
|
|
|
|
|
def get_reset_devices_confirm_keyboard(language: str = 'ru') -> InlineKeyboardMarkup:
|
|
get_texts(language)
|
|
return InlineKeyboardMarkup(
|
|
inline_keyboard=[
|
|
[InlineKeyboardButton(text='✅ Да, сбросить все устройства', callback_data='confirm_reset_devices')],
|
|
[InlineKeyboardButton(text='❌ Отмена', callback_data='menu_subscription')],
|
|
]
|
|
)
|
|
|
|
|
|
def get_traffic_switch_keyboard(
|
|
current_traffic_gb: int,
|
|
language: str = 'ru',
|
|
subscription_end_date: datetime = None,
|
|
discount_percent: int = 0,
|
|
base_traffic_gb: int = None,
|
|
) -> InlineKeyboardMarkup:
|
|
from app.config import settings
|
|
|
|
# Если базовый трафик не передан, используем текущий
|
|
# (для обратной совместимости и случаев без докупленного трафика)
|
|
if base_traffic_gb is None:
|
|
base_traffic_gb = current_traffic_gb
|
|
|
|
months_multiplier = 1
|
|
period_text = ''
|
|
if subscription_end_date:
|
|
months_multiplier = get_remaining_months(subscription_end_date)
|
|
if months_multiplier > 1:
|
|
period_text = f' (за {months_multiplier} мес)'
|
|
|
|
packages = settings.get_traffic_packages()
|
|
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
|
|
|
|
# Используем базовый трафик для определения цены текущего пакета
|
|
current_price_per_month = settings.get_traffic_price(base_traffic_gb)
|
|
discounted_current_per_month, _ = apply_percentage_discount(
|
|
current_price_per_month,
|
|
discount_percent,
|
|
)
|
|
|
|
buttons = []
|
|
|
|
for package in enabled_packages:
|
|
gb = package['gb']
|
|
price_per_month = package['price']
|
|
discounted_price_per_month, _ = apply_percentage_discount(
|
|
price_per_month,
|
|
discount_percent,
|
|
)
|
|
|
|
price_diff_per_month = discounted_price_per_month - discounted_current_per_month
|
|
total_price_diff = price_diff_per_month * months_multiplier
|
|
|
|
# Сравниваем с базовым трафиком (без докупленного)
|
|
if gb == base_traffic_gb:
|
|
emoji = '✅'
|
|
action_text = ' (текущий)'
|
|
price_text = ''
|
|
elif total_price_diff > 0:
|
|
emoji = '⬆️'
|
|
action_text = ''
|
|
price_text = f' (+{total_price_diff // 100}₽{period_text})'
|
|
if discount_percent > 0:
|
|
discount_total = (price_per_month - current_price_per_month) * months_multiplier - total_price_diff
|
|
if discount_total > 0:
|
|
price_text += f' (скидка {discount_percent}%: -{discount_total // 100}₽)'
|
|
elif total_price_diff < 0:
|
|
emoji = '⬇️'
|
|
action_text = ''
|
|
price_text = ' (без возврата)'
|
|
else:
|
|
emoji = '🔄'
|
|
action_text = ''
|
|
price_text = ' (бесплатно)'
|
|
|
|
if gb == 0:
|
|
traffic_text = 'Безлимит'
|
|
else:
|
|
traffic_text = f'{gb} ГБ'
|
|
|
|
button_text = f'{emoji} {traffic_text}{action_text}{price_text}'
|
|
|
|
buttons.append([InlineKeyboardButton(text=button_text, callback_data=f'switch_traffic_{gb}')])
|
|
|
|
buttons.append(
|
|
[InlineKeyboardButton(text='⬅️ Назад' if language == 'ru' else '⬅️ Back', callback_data='subscription_settings')]
|
|
)
|
|
|
|
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
|
|
|
|
|
def get_confirm_switch_traffic_keyboard(
|
|
new_traffic_gb: int, price_difference: int, language: str = 'ru'
|
|
) -> InlineKeyboardMarkup:
|
|
return InlineKeyboardMarkup(
|
|
inline_keyboard=[
|
|
[
|
|
InlineKeyboardButton(
|
|
text='✅ Подтвердить переключение',
|
|
callback_data=f'confirm_switch_traffic_{new_traffic_gb}_{price_difference}',
|
|
)
|
|
],
|
|
[InlineKeyboardButton(text='❌ Отмена', callback_data='subscription_settings')],
|
|
]
|
|
)
|