Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 26e0800d70 | |||
| d0197aacd7 | |||
| b097c5db44 | |||
| 302f64ad5c | |||
| 23cd2097da | |||
| 58257a6eac | |||
| ea7b982b7b | |||
| 1fcd76db9e | |||
| 66d92cfa51 | |||
| 9dc5ee22ee | |||
| b5df729d97 | |||
| 56e3541b80 | |||
| 9681bce540 | |||
| 7330c729e0 | |||
| 977a19a4c9 | |||
| d226fedf75 | |||
| c9ee1da728 | |||
| d6753501e7 | |||
| 48101eee39 | |||
| 16e771f1c3 | |||
| 0aa49a6a94 | |||
| c810619a13 | |||
| 817d277091 | |||
| d3a822e8b3 | |||
| ec8c37744a | |||
| 732c36630c | |||
| 498d2f04b6 | |||
| d167147725 | |||
| 19800971d6 | |||
| 4b457240b9 | |||
| b63c5867d4 | |||
| 598dd8b6c1 | |||
| 6cd399cea7 | |||
| 466033d04c | |||
| 99bca0220b | |||
| a10b9f0ad7 | |||
| 825d549822 | |||
| 03cb06e757 | |||
| 113b33e68d | |||
| f3a327c9c7 | |||
| 2ca4a989e2 | |||
| 7339f2cd4f | |||
| 827897b771 | |||
| eb5064d591 | |||
| 177862f178 | |||
| 7276dcf1e0 |
@@ -113,6 +113,11 @@ PRICE_90_DAYS=36900
|
||||
PRICE_180_DAYS=69900
|
||||
PRICE_360_DAYS=109900
|
||||
|
||||
# Скидка для Базовых Юзеров (Для других Промогрупп не работает!)
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED=false
|
||||
# 60:10 = скидка 10% на все доп услуги. 90:20 = 20% за 90 дней и тд
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
|
||||
|
||||
# Выводимые пакеты трафика и их цены в копейках
|
||||
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true"
|
||||
|
||||
|
||||
@@ -36,15 +36,15 @@ jobs:
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🏷️ Собираем релизную версию: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.3.6-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.7-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🚀 Собираем версию из main: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.3.6-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.7-dev-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
|
||||
echo "🧪 Собираем dev версию: $VERSION"
|
||||
else
|
||||
VERSION="v2.3.6-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.7-pr-$(git rev-parse --short HEAD)"
|
||||
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Собираем PR версию: $VERSION"
|
||||
fi
|
||||
|
||||
@@ -49,13 +49,13 @@ jobs:
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
echo "🏷️ Building release version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/main ]]; then
|
||||
VERSION="v2.3.6-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.7-$(git rev-parse --short HEAD)"
|
||||
echo "🚀 Building main version: $VERSION"
|
||||
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
|
||||
VERSION="v2.3.6-dev-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.7-dev-$(git rev-parse --short HEAD)"
|
||||
echo "🧪 Building dev version: $VERSION"
|
||||
else
|
||||
VERSION="v2.3.6-pr-$(git rev-parse --short HEAD)"
|
||||
VERSION="v2.3.7-pr-$(git rev-parse --short HEAD)"
|
||||
echo "🔀 Building PR version: $VERSION"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v2.3.6"
|
||||
ARG VERSION="v2.3.7"
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -159,6 +159,12 @@ PRICE_90_DAYS=36900
|
||||
PRICE_180_DAYS=69900
|
||||
PRICE_360_DAYS=109900
|
||||
|
||||
# Скидка для Базовых Юзеров (Для других Промогрупп не работает!)
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED=false
|
||||
# 60:10 = скидка 10% на все доп услуги. 90:20 = 20% за 90 дней и тд
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
|
||||
|
||||
|
||||
# Выводимые пакеты трафика и их цены в копейках
|
||||
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true"
|
||||
|
||||
@@ -331,6 +337,11 @@ PRICE_90_DAYS=36900
|
||||
PRICE_180_DAYS=69900
|
||||
PRICE_360_DAYS=109900
|
||||
|
||||
# Скидка для Базовых Юзеров (Для других Промогрупп не работает!)
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED=false
|
||||
# 60:10 = скидка 10% на все доп услуги. 90:20 = 20% за 90 дней и тд
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS=60:10,90:20,180:40,360:70
|
||||
|
||||
# Выводимые пакеты трафика и их цены в копейках
|
||||
TRAFFIC_PACKAGES_CONFIG="5:2000:false,10:3500:false,25:7000:false,50:11000:true,100:15000:true,250:17000:false,500:19000:false,1000:19500:true,0:20000:true"
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ from app.handlers.admin import (
|
||||
statistics as admin_statistics,
|
||||
servers as admin_servers,
|
||||
maintenance as admin_maintenance,
|
||||
promo_groups as admin_promo_groups,
|
||||
campaigns as admin_campaigns,
|
||||
user_messages as admin_user_messages,
|
||||
updates as admin_updates,
|
||||
@@ -127,6 +128,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_rules.register_handlers(dp)
|
||||
admin_remnawave.register_handlers(dp)
|
||||
admin_statistics.register_handlers(dp)
|
||||
admin_promo_groups.register_handlers(dp)
|
||||
admin_campaigns.register_handlers(dp)
|
||||
admin_maintenance.register_handlers(dp)
|
||||
admin_user_messages.register_handlers(dp)
|
||||
|
||||
+79
-7
@@ -72,7 +72,7 @@ class Settings(BaseSettings):
|
||||
PRICE_90_DAYS: int = 269000
|
||||
PRICE_180_DAYS: int = 499000
|
||||
PRICE_360_DAYS: int = 899000
|
||||
|
||||
|
||||
PRICE_TRAFFIC_5GB: int = 2000
|
||||
PRICE_TRAFFIC_10GB: int = 3500
|
||||
PRICE_TRAFFIC_25GB: int = 7000
|
||||
@@ -84,9 +84,12 @@ class Settings(BaseSettings):
|
||||
PRICE_TRAFFIC_UNLIMITED: int = 20000
|
||||
|
||||
TRAFFIC_PACKAGES_CONFIG: str = ""
|
||||
|
||||
|
||||
PRICE_PER_DEVICE: int = 5000
|
||||
|
||||
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: bool = False
|
||||
BASE_PROMO_GROUP_PERIOD_DISCOUNTS: str = ""
|
||||
|
||||
TRAFFIC_SELECTION_MODE: str = "selectable"
|
||||
FIXED_TRAFFIC_LIMIT_GB: int = 100
|
||||
|
||||
@@ -399,7 +402,46 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_maintenance_check_interval(self) -> int:
|
||||
return self.MAINTENANCE_CHECK_INTERVAL
|
||||
|
||||
|
||||
def is_base_promo_group_period_discount_enabled(self) -> bool:
|
||||
return self.BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED
|
||||
|
||||
def get_base_promo_group_period_discounts(self) -> Dict[int, int]:
|
||||
try:
|
||||
config_str = (self.BASE_PROMO_GROUP_PERIOD_DISCOUNTS or "").strip()
|
||||
if not config_str:
|
||||
return {}
|
||||
|
||||
discounts: Dict[int, int] = {}
|
||||
for part in config_str.split(','):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
period_and_discount = part.split(':')
|
||||
if len(period_and_discount) != 2:
|
||||
continue
|
||||
|
||||
period_str, discount_str = period_and_discount
|
||||
try:
|
||||
period_days = int(period_str.strip())
|
||||
discount_percent = int(discount_str.strip())
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
discounts[period_days] = max(0, min(100, discount_percent))
|
||||
|
||||
return discounts
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def get_base_promo_group_period_discount(self, period_days: Optional[int]) -> int:
|
||||
if not period_days or not self.is_base_promo_group_period_discount_enabled():
|
||||
return 0
|
||||
|
||||
discounts = self.get_base_promo_group_period_discounts()
|
||||
return discounts.get(period_days, 0)
|
||||
|
||||
def is_maintenance_auto_enable(self) -> bool:
|
||||
return self.MAINTENANCE_AUTO_ENABLE
|
||||
|
||||
@@ -604,13 +646,43 @@ class Settings(BaseSettings):
|
||||
{"gb": 0, "price": self.PRICE_TRAFFIC_UNLIMITED, "enabled": True},
|
||||
]
|
||||
|
||||
def get_traffic_price(self, gb: int) -> int:
|
||||
def get_traffic_price(self, gb: Optional[int]) -> int:
|
||||
packages = self.get_traffic_packages()
|
||||
enabled_packages = [pkg for pkg in packages if pkg["enabled"]]
|
||||
|
||||
for package in packages:
|
||||
if package["gb"] == gb and package["enabled"]:
|
||||
if not enabled_packages:
|
||||
return 0
|
||||
|
||||
if gb is None:
|
||||
gb = 0
|
||||
|
||||
for package in enabled_packages:
|
||||
if package["gb"] == gb:
|
||||
return package["price"]
|
||||
|
||||
unlimited_package = next((pkg for pkg in enabled_packages if pkg["gb"] == 0), None)
|
||||
|
||||
if gb <= 0:
|
||||
return unlimited_package["price"] if unlimited_package else 0
|
||||
|
||||
finite_packages = [pkg for pkg in enabled_packages if pkg["gb"] > 0]
|
||||
|
||||
if not finite_packages:
|
||||
return unlimited_package["price"] if unlimited_package else 0
|
||||
|
||||
max_package = max(finite_packages, key=lambda x: x["gb"])
|
||||
|
||||
if gb >= max_package["gb"]:
|
||||
return unlimited_package["price"] if unlimited_package else max_package["price"]
|
||||
|
||||
suitable_packages = [pkg for pkg in finite_packages if pkg["gb"] >= gb]
|
||||
|
||||
if suitable_packages:
|
||||
nearest_package = min(suitable_packages, key=lambda x: x["gb"])
|
||||
return nearest_package["price"]
|
||||
|
||||
return unlimited_package["price"] if unlimited_package else 0
|
||||
|
||||
def _clean_support_contact(self) -> str:
|
||||
return (self.SUPPORT_USERNAME or "").strip()
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import logging
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import PromoGroup, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_promo_groups_with_counts(
|
||||
db: AsyncSession,
|
||||
) -> List[Tuple[PromoGroup, int]]:
|
||||
result = await db.execute(
|
||||
select(PromoGroup, func.count(User.id))
|
||||
.outerjoin(User, User.promo_group_id == PromoGroup.id)
|
||||
.group_by(PromoGroup.id)
|
||||
.order_by(PromoGroup.is_default.desc(), PromoGroup.name)
|
||||
)
|
||||
return result.all()
|
||||
|
||||
|
||||
async def get_promo_group_by_id(db: AsyncSession, group_id: int) -> Optional[PromoGroup]:
|
||||
return await db.get(PromoGroup, group_id)
|
||||
|
||||
|
||||
async def get_default_promo_group(db: AsyncSession) -> Optional[PromoGroup]:
|
||||
result = await db.execute(
|
||||
select(PromoGroup).where(PromoGroup.is_default.is_(True))
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def create_promo_group(
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
*,
|
||||
server_discount_percent: int,
|
||||
traffic_discount_percent: int,
|
||||
device_discount_percent: int,
|
||||
) -> PromoGroup:
|
||||
promo_group = PromoGroup(
|
||||
name=name.strip(),
|
||||
server_discount_percent=max(0, min(100, server_discount_percent)),
|
||||
traffic_discount_percent=max(0, min(100, traffic_discount_percent)),
|
||||
device_discount_percent=max(0, min(100, device_discount_percent)),
|
||||
is_default=False,
|
||||
)
|
||||
|
||||
db.add(promo_group)
|
||||
await db.commit()
|
||||
await db.refresh(promo_group)
|
||||
|
||||
logger.info(
|
||||
"Создана промогруппа '%s' с скидками (servers=%s%%, traffic=%s%%, devices=%s%%)",
|
||||
promo_group.name,
|
||||
promo_group.server_discount_percent,
|
||||
promo_group.traffic_discount_percent,
|
||||
promo_group.device_discount_percent,
|
||||
)
|
||||
|
||||
return promo_group
|
||||
|
||||
|
||||
async def update_promo_group(
|
||||
db: AsyncSession,
|
||||
group: PromoGroup,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
server_discount_percent: Optional[int] = None,
|
||||
traffic_discount_percent: Optional[int] = None,
|
||||
device_discount_percent: Optional[int] = None,
|
||||
) -> PromoGroup:
|
||||
if name is not None:
|
||||
group.name = name.strip()
|
||||
if server_discount_percent is not None:
|
||||
group.server_discount_percent = max(0, min(100, server_discount_percent))
|
||||
if traffic_discount_percent is not None:
|
||||
group.traffic_discount_percent = max(0, min(100, traffic_discount_percent))
|
||||
if device_discount_percent is not None:
|
||||
group.device_discount_percent = max(0, min(100, device_discount_percent))
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(group)
|
||||
|
||||
logger.info(
|
||||
"Обновлена промогруппа '%s' (id=%s)",
|
||||
group.name,
|
||||
group.id,
|
||||
)
|
||||
return group
|
||||
|
||||
|
||||
async def delete_promo_group(db: AsyncSession, group: PromoGroup) -> bool:
|
||||
if group.is_default:
|
||||
logger.warning("Попытка удалить базовую промогруппу запрещена")
|
||||
return False
|
||||
|
||||
default_group = await get_default_promo_group(db)
|
||||
if not default_group:
|
||||
logger.error("Не найдена базовая промогруппа для reassignment")
|
||||
return False
|
||||
|
||||
await db.execute(
|
||||
update(User)
|
||||
.where(User.promo_group_id == group.id)
|
||||
.values(promo_group_id=default_group.id)
|
||||
)
|
||||
await db.delete(group)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"Промогруппа '%s' (id=%s) удалена, пользователи переведены в '%s'",
|
||||
group.name,
|
||||
group.id,
|
||||
default_group.name,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def get_promo_group_members(
|
||||
db: AsyncSession,
|
||||
group_id: int,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
) -> List[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.where(User.promo_group_id == group_id)
|
||||
.order_by(User.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def count_promo_group_members(db: AsyncSession, group_id: int) -> int:
|
||||
result = await db.execute(
|
||||
select(func.count(User.id)).where(User.promo_group_id == group_id)
|
||||
)
|
||||
return result.scalar_one()
|
||||
@@ -6,8 +6,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
Subscription, SubscriptionStatus, User,
|
||||
SubscriptionServer
|
||||
Subscription,
|
||||
SubscriptionStatus,
|
||||
User,
|
||||
SubscriptionServer,
|
||||
PromoGroup,
|
||||
)
|
||||
from app.database.crud.notification import clear_notifications
|
||||
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
|
||||
@@ -495,12 +498,34 @@ async def get_servers_monthly_prices(
|
||||
prices.append(price)
|
||||
return prices
|
||||
|
||||
def _get_discount_percent(
|
||||
user: Optional[User],
|
||||
promo_group: Optional[PromoGroup],
|
||||
category: str,
|
||||
*,
|
||||
period_days: Optional[int] = None,
|
||||
) -> int:
|
||||
if user is not None:
|
||||
try:
|
||||
return user.get_promo_discount(category, period_days)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
if promo_group is not None:
|
||||
return promo_group.get_discount_percent(category, period_days)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
async def calculate_subscription_total_cost(
|
||||
db: AsyncSession,
|
||||
period_days: int,
|
||||
traffic_gb: int,
|
||||
server_squad_ids: List[int],
|
||||
devices: int
|
||||
devices: int,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
promo_group: Optional[PromoGroup] = None,
|
||||
) -> Tuple[int, dict]:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
@@ -508,39 +533,98 @@ async def calculate_subscription_total_cost(
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
traffic_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
total_traffic_price = discounted_traffic_per_month * months_in_period
|
||||
total_traffic_discount = traffic_discount_per_month * months_in_period
|
||||
|
||||
servers_prices = await get_servers_monthly_prices(db, server_squad_ids)
|
||||
servers_price_per_month = sum(servers_prices)
|
||||
total_servers_price = servers_price_per_month * months_in_period
|
||||
|
||||
servers_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
period_days=period_days,
|
||||
)
|
||||
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
|
||||
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
|
||||
total_servers_price = discounted_servers_per_month * months_in_period
|
||||
total_servers_discount = servers_discount_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
devices_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
total_devices_price = discounted_devices_per_month * months_in_period
|
||||
total_devices_discount = devices_discount_per_month * months_in_period
|
||||
|
||||
total_cost = base_price + total_traffic_price + total_servers_price + total_devices_price
|
||||
|
||||
|
||||
details = {
|
||||
'base_price': base_price,
|
||||
'traffic_price_per_month': traffic_price_per_month,
|
||||
'traffic_discount_percent': traffic_discount_percent,
|
||||
'traffic_discount_total': total_traffic_discount,
|
||||
'total_traffic_price': total_traffic_price,
|
||||
'servers_price_per_month': servers_price_per_month,
|
||||
'servers_discount_percent': servers_discount_percent,
|
||||
'servers_discount_total': total_servers_discount,
|
||||
'total_servers_price': total_servers_price,
|
||||
'devices_price_per_month': devices_price_per_month,
|
||||
'devices_discount_percent': devices_discount_percent,
|
||||
'devices_discount_total': total_devices_discount,
|
||||
'total_devices_price': total_devices_price,
|
||||
'months_in_period': months_in_period,
|
||||
'servers_individual_prices': [price * months_in_period for price in servers_prices]
|
||||
'servers_individual_prices': [
|
||||
(price - (price * servers_discount_percent // 100)) * months_in_period
|
||||
for price in servers_prices
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
logger.info(f"📊 Расчет стоимости подписки на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" Базовый период: {base_price/100}₽")
|
||||
if total_traffic_price > 0:
|
||||
logger.info(f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}₽")
|
||||
message = (
|
||||
f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}₽"
|
||||
)
|
||||
if total_traffic_discount > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{total_traffic_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_servers_price > 0:
|
||||
logger.info(f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}₽")
|
||||
message = (
|
||||
f" Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}₽"
|
||||
)
|
||||
if total_servers_discount > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%: -{total_servers_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_devices_price > 0:
|
||||
logger.info(f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}₽")
|
||||
message = (
|
||||
f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}₽"
|
||||
)
|
||||
if total_devices_discount > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{total_devices_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" ИТОГО: {total_cost/100}₽")
|
||||
|
||||
return total_cost, details
|
||||
@@ -614,19 +698,33 @@ async def remove_subscription_servers(
|
||||
async def get_subscription_renewal_cost(
|
||||
db: AsyncSession,
|
||||
subscription_id: int,
|
||||
period_days: int
|
||||
period_days: int,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
promo_group: Optional[PromoGroup] = None,
|
||||
) -> int:
|
||||
try:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
subscription = await db.get(Subscription, subscription_id)
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.options(
|
||||
selectinload(Subscription.user).selectinload(User.promo_group),
|
||||
)
|
||||
.where(Subscription.id == subscription_id)
|
||||
)
|
||||
subscription = result.scalar_one_or_none()
|
||||
if not subscription:
|
||||
return base_price
|
||||
|
||||
|
||||
if user is None:
|
||||
user = subscription.user
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
servers_info = await get_subscription_servers(db, subscription_id)
|
||||
servers_price_per_month = 0
|
||||
for server_info in servers_info:
|
||||
@@ -637,26 +735,74 @@ async def get_subscription_renewal_cost(
|
||||
)
|
||||
current_server_price = result.scalar() or 0
|
||||
servers_price_per_month += current_server_price
|
||||
|
||||
total_servers_cost = servers_price_per_month * months_in_period
|
||||
|
||||
|
||||
servers_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
period_days=period_days,
|
||||
)
|
||||
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
|
||||
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
|
||||
total_servers_cost = discounted_servers_per_month * months_in_period
|
||||
total_servers_discount = servers_discount_per_month * months_in_period
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
total_traffic_cost = traffic_price_per_month * months_in_period
|
||||
|
||||
traffic_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
total_traffic_cost = discounted_traffic_per_month * months_in_period
|
||||
total_traffic_discount = traffic_discount_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_cost = devices_price_per_month * months_in_period
|
||||
|
||||
devices_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
total_devices_cost = discounted_devices_per_month * months_in_period
|
||||
total_devices_discount = devices_discount_per_month * months_in_period
|
||||
|
||||
total_cost = base_price + total_servers_cost + total_traffic_cost + total_devices_cost
|
||||
|
||||
|
||||
logger.info(f"💰 Расчет продления подписки {subscription_id} на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" 📅 Период: {base_price/100}₽")
|
||||
if total_servers_cost > 0:
|
||||
logger.info(f" 🌍 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_cost/100}₽")
|
||||
message = (
|
||||
f" 🌍 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_cost/100}₽"
|
||||
)
|
||||
if total_servers_discount > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%: -{total_servers_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_traffic_cost > 0:
|
||||
logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_cost/100}₽")
|
||||
message = (
|
||||
f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_cost/100}₽"
|
||||
)
|
||||
if total_traffic_discount > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{total_traffic_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_devices_cost > 0:
|
||||
logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_cost/100}₽")
|
||||
message = (
|
||||
f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_cost/100}₽"
|
||||
)
|
||||
if total_devices_discount > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{total_devices_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" 💎 ИТОГО: {total_cost/100}₽")
|
||||
|
||||
return total_cost
|
||||
@@ -671,27 +817,65 @@ async def calculate_addon_cost_for_remaining_period(
|
||||
subscription: Subscription,
|
||||
additional_traffic_gb: int = 0,
|
||||
additional_devices: int = 0,
|
||||
additional_server_ids: List[int] = None
|
||||
additional_server_ids: List[int] = None,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
promo_group: Optional[PromoGroup] = None,
|
||||
) -> int:
|
||||
if additional_server_ids is None:
|
||||
additional_server_ids = []
|
||||
|
||||
|
||||
months_to_pay = get_remaining_months(subscription.end_date)
|
||||
|
||||
period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None
|
||||
|
||||
total_cost = 0
|
||||
|
||||
|
||||
if user is None:
|
||||
user = getattr(subscription, "user", None)
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
if additional_traffic_gb > 0:
|
||||
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
|
||||
traffic_total_cost = traffic_price_per_month * months_to_pay
|
||||
traffic_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
traffic_total_cost = discounted_traffic_per_month * months_to_pay
|
||||
total_cost += traffic_total_cost
|
||||
logger.info(f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес × {months_to_pay} = {traffic_total_cost/100}₽")
|
||||
|
||||
message = (
|
||||
f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес × {months_to_pay} = {traffic_total_cost/100}₽"
|
||||
)
|
||||
if traffic_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_to_pay/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
if additional_devices > 0:
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_total_cost = devices_price_per_month * months_to_pay
|
||||
devices_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
devices_total_cost = discounted_devices_per_month * months_to_pay
|
||||
total_cost += devices_total_cost
|
||||
logger.info(f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес × {months_to_pay} = {devices_total_cost/100}₽")
|
||||
|
||||
message = (
|
||||
f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес × {months_to_pay} = {devices_total_cost/100}₽"
|
||||
)
|
||||
if devices_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_to_pay/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
if additional_server_ids:
|
||||
from app.database.models import ServerSquad
|
||||
for server_id in additional_server_ids:
|
||||
@@ -702,9 +886,24 @@ async def calculate_addon_cost_for_remaining_period(
|
||||
server_data = result.first()
|
||||
if server_data:
|
||||
server_price_per_month, server_name = server_data
|
||||
server_total_cost = server_price_per_month * months_to_pay
|
||||
servers_discount_percent = _get_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
server_discount_per_month = server_price_per_month * servers_discount_percent // 100
|
||||
discounted_server_per_month = server_price_per_month - server_discount_per_month
|
||||
server_total_cost = discounted_server_per_month * months_to_pay
|
||||
total_cost += server_total_cost
|
||||
logger.info(f"Сервер {server_name}: {server_price_per_month/100}₽/мес × {months_to_pay} = {server_total_cost/100}₽")
|
||||
message = (
|
||||
f"Сервер {server_name}: {server_price_per_month/100}₽/мес × {months_to_pay} = {server_total_cost/100}₽"
|
||||
)
|
||||
if server_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%: -{server_discount_per_month * months_to_pay/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
logger.info(f"💰 Итого доплата за {months_to_pay} мес: {total_cost/100}₽")
|
||||
return total_cost
|
||||
|
||||
@@ -7,8 +7,9 @@ from sqlalchemy import select, and_, or_, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import User, UserStatus, Subscription, Transaction
|
||||
from app.database.models import User, UserStatus, Subscription, Transaction, PromoGroup
|
||||
from app.config import settings
|
||||
from app.database.crud.promo_group import get_default_promo_group
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,7 +23,10 @@ def generate_referral_code() -> str:
|
||||
async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(User.id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
@@ -36,7 +40,10 @@ async def get_user_by_id(db: AsyncSession, user_id: int) -> Optional[User]:
|
||||
async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optional[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(User.telegram_id == telegram_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
@@ -49,7 +56,9 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> Optiona
|
||||
|
||||
async def get_user_by_referral_code(db: AsyncSession, referral_code: str) -> Optional[User]:
|
||||
result = await db.execute(
|
||||
select(User).where(User.referral_code == referral_code)
|
||||
select(User)
|
||||
.options(selectinload(User.promo_group))
|
||||
.where(User.referral_code == referral_code)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@@ -82,6 +91,20 @@ async def create_user(
|
||||
from app.utils.user_utils import generate_unique_referral_code
|
||||
referral_code = await generate_unique_referral_code(db, telegram_id)
|
||||
|
||||
default_group = await get_default_promo_group(db)
|
||||
if not default_group:
|
||||
default_group = PromoGroup(
|
||||
name="Базовый юзер",
|
||||
server_discount_percent=0,
|
||||
traffic_discount_percent=0,
|
||||
device_discount_percent=0,
|
||||
is_default=True,
|
||||
)
|
||||
db.add(default_group)
|
||||
await db.flush()
|
||||
|
||||
promo_group_id = default_group.id
|
||||
|
||||
user = User(
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
@@ -92,15 +115,19 @@ async def create_user(
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
has_had_paid_subscription=False,
|
||||
has_made_first_topup=False
|
||||
has_made_first_topup=False,
|
||||
promo_group_id=promo_group_id,
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
if default_group:
|
||||
user.promo_group = default_group
|
||||
|
||||
logger.info(f"✅ Создан пользователь {telegram_id} с реферальным кодом {referral_code}")
|
||||
|
||||
|
||||
return user
|
||||
|
||||
|
||||
@@ -281,7 +308,10 @@ async def get_users_count(
|
||||
async def get_referrals(db: AsyncSession, user_id: int) -> List[User]:
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(User.referred_by_id == user_id)
|
||||
.order_by(User.created_at.desc())
|
||||
)
|
||||
@@ -293,7 +323,10 @@ async def get_inactive_users(db: AsyncSession, months: int = 3) -> List[User]:
|
||||
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.options(selectinload(User.subscription))
|
||||
.options(
|
||||
selectinload(User.subscription),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
User.last_activity < threshold_date,
|
||||
|
||||
@@ -155,6 +155,42 @@ class CryptoBotPayment(Base):
|
||||
return f"<CryptoBotPayment(id={self.id}, invoice_id={self.invoice_id}, amount={self.amount} {self.asset}, status={self.status})>"
|
||||
|
||||
|
||||
class PromoGroup(Base):
|
||||
__tablename__ = "promo_groups"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), unique=True, nullable=False)
|
||||
server_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
traffic_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
device_discount_percent = Column(Integer, nullable=False, default=0)
|
||||
is_default = Column(Boolean, nullable=False, default=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
users = relationship("User", back_populates="promo_group")
|
||||
|
||||
def get_discount_percent(self, category: str, period_days: Optional[int] = None) -> int:
|
||||
mapping = {
|
||||
"servers": self.server_discount_percent,
|
||||
"traffic": self.traffic_discount_percent,
|
||||
"devices": self.device_discount_percent,
|
||||
}
|
||||
percent = mapping.get(category, 0)
|
||||
|
||||
if self.is_default and period_days is not None:
|
||||
try:
|
||||
from app.config import settings
|
||||
|
||||
discounts = settings.get_base_promo_group_period_discounts()
|
||||
if period_days in discounts:
|
||||
period_discount = discounts[period_days]
|
||||
percent = period_discount
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return max(0, min(100, percent))
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
@@ -185,6 +221,8 @@ class User(Base):
|
||||
vless_uuid = Column(String(255), nullable=True)
|
||||
ss_password = Column(String(255), nullable=True)
|
||||
has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
promo_group = relationship("PromoGroup", back_populates="users")
|
||||
|
||||
@property
|
||||
def balance_rubles(self) -> float:
|
||||
@@ -194,6 +232,11 @@ class User(Base):
|
||||
def full_name(self) -> str:
|
||||
parts = [self.first_name, self.last_name]
|
||||
return " ".join(filter(None, parts)) or self.username or f"ID{self.telegram_id}"
|
||||
|
||||
def get_promo_discount(self, category: str, period_days: Optional[int] = None) -> int:
|
||||
if not self.promo_group:
|
||||
return 0
|
||||
return self.promo_group.get_discount_percent(category, period_days)
|
||||
|
||||
def add_balance(self, kopeks: int) -> None:
|
||||
self.balance_kopeks += kopeks
|
||||
|
||||
@@ -74,6 +74,104 @@ async def check_column_exists(table_name: str, column_name: str) -> bool:
|
||||
logger.error(f"Ошибка проверки существования колонки {column_name}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def check_constraint_exists(table_name: str, constraint_name: str) -> bool:
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "postgresql":
|
||||
result = await conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = :table_name
|
||||
AND constraint_name = :constraint_name
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name, "constraint_name": constraint_name},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
|
||||
if db_type == "mysql":
|
||||
result = await conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = :table_name
|
||||
AND constraint_name = :constraint_name
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name, "constraint_name": constraint_name},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
|
||||
if db_type == "sqlite":
|
||||
result = await conn.execute(text(f"PRAGMA foreign_key_list({table_name})"))
|
||||
rows = result.fetchall()
|
||||
return any(row[5] == constraint_name for row in rows)
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка проверки существования ограничения {constraint_name} для {table_name}: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def check_index_exists(table_name: str, index_name: str) -> bool:
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == "postgresql":
|
||||
result = await conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename = :table_name
|
||||
AND indexname = :index_name
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name, "index_name": index_name},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
|
||||
if db_type == "mysql":
|
||||
result = await conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = :table_name
|
||||
AND index_name = :index_name
|
||||
"""
|
||||
),
|
||||
{"table_name": table_name, "index_name": index_name},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
|
||||
if db_type == "sqlite":
|
||||
result = await conn.execute(text(f"PRAGMA index_list({table_name})"))
|
||||
rows = result.fetchall()
|
||||
return any(row[1] == index_name for row in rows)
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Ошибка проверки существования индекса {index_name} для {table_name}: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
async def create_cryptobot_payments_table():
|
||||
table_exists = await check_table_exists('cryptobot_payments')
|
||||
if table_exists:
|
||||
@@ -248,6 +346,276 @@ async def create_user_messages_table():
|
||||
logger.error(f"Ошибка создания таблицы user_messages: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def ensure_promo_groups_setup():
|
||||
logger.info("=== НАСТРОЙКА ПРОМО ГРУПП ===")
|
||||
|
||||
try:
|
||||
promo_table_exists = await check_table_exists("promo_groups")
|
||||
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if not promo_table_exists:
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS promo_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
server_discount_percent INTEGER NOT NULL DEFAULT 0,
|
||||
traffic_discount_percent INTEGER NOT NULL DEFAULT 0,
|
||||
device_discount_percent INTEGER NOT NULL DEFAULT 0,
|
||||
is_default BOOLEAN NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_promo_groups_name ON promo_groups(name)"
|
||||
)
|
||||
)
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS promo_groups (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
server_discount_percent INTEGER NOT NULL DEFAULT 0,
|
||||
traffic_discount_percent INTEGER NOT NULL DEFAULT 0,
|
||||
device_discount_percent INTEGER NOT NULL DEFAULT 0,
|
||||
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uq_promo_groups_name UNIQUE (name)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
elif db_type == "mysql":
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS promo_groups (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
server_discount_percent INT NOT NULL DEFAULT 0,
|
||||
traffic_discount_percent INT NOT NULL DEFAULT 0,
|
||||
device_discount_percent INT NOT NULL DEFAULT 0,
|
||||
is_default TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_promo_groups_name (name)
|
||||
) ENGINE=InnoDB
|
||||
"""
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для promo_groups: {db_type}")
|
||||
return False
|
||||
|
||||
logger.info("Создана таблица promo_groups")
|
||||
|
||||
if db_type == "postgresql" and not await check_constraint_exists(
|
||||
"promo_groups", "uq_promo_groups_name"
|
||||
):
|
||||
try:
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE promo_groups ADD CONSTRAINT uq_promo_groups_name UNIQUE (name)"
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Не удалось добавить уникальное ограничение uq_promo_groups_name: {e}"
|
||||
)
|
||||
|
||||
column_exists = await check_column_exists("users", "promo_group_id")
|
||||
|
||||
if not column_exists:
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(text("ALTER TABLE users ADD COLUMN promo_group_id INTEGER"))
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(text("ALTER TABLE users ADD COLUMN promo_group_id INTEGER"))
|
||||
elif db_type == "mysql":
|
||||
await conn.execute(text("ALTER TABLE users ADD COLUMN promo_group_id INT"))
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для promo_group_id: {db_type}")
|
||||
return False
|
||||
|
||||
logger.info("Добавлена колонка users.promo_group_id")
|
||||
|
||||
index_exists = await check_index_exists("users", "ix_users_promo_group_id")
|
||||
|
||||
if not index_exists:
|
||||
try:
|
||||
if db_type == "sqlite":
|
||||
await conn.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS ix_users_promo_group_id ON users(promo_group_id)")
|
||||
)
|
||||
elif db_type == "postgresql":
|
||||
await conn.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS ix_users_promo_group_id ON users(promo_group_id)")
|
||||
)
|
||||
elif db_type == "mysql":
|
||||
await conn.execute(
|
||||
text("CREATE INDEX ix_users_promo_group_id ON users(promo_group_id)")
|
||||
)
|
||||
logger.info("Создан индекс ix_users_promo_group_id")
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось создать индекс ix_users_promo_group_id: {e}")
|
||||
|
||||
default_group_name = "Базовый юзер"
|
||||
default_group_id = None
|
||||
|
||||
result = await conn.execute(
|
||||
text(
|
||||
"SELECT id, is_default FROM promo_groups WHERE name = :name LIMIT 1"
|
||||
),
|
||||
{"name": default_group_name},
|
||||
)
|
||||
row = result.fetchone()
|
||||
|
||||
if row:
|
||||
default_group_id = row[0]
|
||||
if not row[1]:
|
||||
await conn.execute(
|
||||
text(
|
||||
"UPDATE promo_groups SET is_default = :is_default WHERE id = :group_id"
|
||||
),
|
||||
{"is_default": True, "group_id": default_group_id},
|
||||
)
|
||||
else:
|
||||
result = await conn.execute(
|
||||
text(
|
||||
"SELECT id FROM promo_groups WHERE is_default = :is_default LIMIT 1"
|
||||
),
|
||||
{"is_default": True},
|
||||
)
|
||||
existing_default = result.fetchone()
|
||||
|
||||
if existing_default:
|
||||
default_group_id = existing_default[0]
|
||||
else:
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO promo_groups (
|
||||
name,
|
||||
server_discount_percent,
|
||||
traffic_discount_percent,
|
||||
device_discount_percent,
|
||||
is_default
|
||||
) VALUES (:name, 0, 0, 0, :is_default)
|
||||
"""
|
||||
),
|
||||
{"name": default_group_name, "is_default": True},
|
||||
)
|
||||
|
||||
result = await conn.execute(
|
||||
text(
|
||||
"SELECT id FROM promo_groups WHERE name = :name LIMIT 1"
|
||||
),
|
||||
{"name": default_group_name},
|
||||
)
|
||||
row = result.fetchone()
|
||||
default_group_id = row[0] if row else None
|
||||
|
||||
if default_group_id is None:
|
||||
logger.error("Не удалось определить идентификатор базовой промо-группы")
|
||||
return False
|
||||
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE users
|
||||
SET promo_group_id = :group_id
|
||||
WHERE promo_group_id IS NULL
|
||||
"""
|
||||
),
|
||||
{"group_id": default_group_id},
|
||||
)
|
||||
|
||||
if db_type == "postgresql":
|
||||
constraint_exists = await check_constraint_exists(
|
||||
"users", "fk_users_promo_group_id_promo_groups"
|
||||
)
|
||||
if not constraint_exists:
|
||||
try:
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT fk_users_promo_group_id_promo_groups
|
||||
FOREIGN KEY (promo_group_id)
|
||||
REFERENCES promo_groups(id)
|
||||
ON DELETE RESTRICT
|
||||
"""
|
||||
)
|
||||
)
|
||||
logger.info("Добавлен внешний ключ users -> promo_groups")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Не удалось добавить внешний ключ users.promo_group_id: {e}"
|
||||
)
|
||||
|
||||
try:
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE users ALTER COLUMN promo_group_id SET NOT NULL"
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Не удалось сделать users.promo_group_id NOT NULL: {e}"
|
||||
)
|
||||
|
||||
elif db_type == "mysql":
|
||||
constraint_exists = await check_constraint_exists(
|
||||
"users", "fk_users_promo_group_id_promo_groups"
|
||||
)
|
||||
if not constraint_exists:
|
||||
try:
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT fk_users_promo_group_id_promo_groups
|
||||
FOREIGN KEY (promo_group_id)
|
||||
REFERENCES promo_groups(id)
|
||||
ON DELETE RESTRICT
|
||||
"""
|
||||
)
|
||||
)
|
||||
logger.info("Добавлен внешний ключ users -> promo_groups")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Не удалось добавить внешний ключ users.promo_group_id: {e}"
|
||||
)
|
||||
|
||||
try:
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE users MODIFY promo_group_id INT NOT NULL"
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Не удалось сделать users.promo_group_id NOT NULL: {e}"
|
||||
)
|
||||
|
||||
logger.info("✅ Промо группы настроены")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка настройки промо групп: {e}")
|
||||
return False
|
||||
|
||||
async def add_welcome_text_is_enabled_column():
|
||||
column_exists = await check_column_exists('welcome_texts', 'is_enabled')
|
||||
if column_exists:
|
||||
@@ -688,7 +1056,14 @@ async def run_universal_migration():
|
||||
logger.info("✅ Медиа поля в broadcast_history готовы")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с добавлением медиа полей")
|
||||
|
||||
|
||||
logger.info("=== НАСТРОЙКА ПРОМО ГРУПП ===")
|
||||
promo_groups_ready = await ensure_promo_groups_setup()
|
||||
if promo_groups_ready:
|
||||
logger.info("✅ Промо группы готовы")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с настройкой промо групп")
|
||||
|
||||
logger.info("=== ОБНОВЛЕНИЕ ВНЕШНИХ КЛЮЧЕЙ ===")
|
||||
fk_updated = await fix_foreign_keys_for_user_deletion()
|
||||
if fk_updated:
|
||||
@@ -756,10 +1131,12 @@ async def check_migration_status():
|
||||
"cryptobot_table": False,
|
||||
"user_messages_table": False,
|
||||
"welcome_texts_table": False,
|
||||
"welcome_texts_is_enabled_column": False,
|
||||
"broadcast_history_media_fields": False,
|
||||
"welcome_texts_is_enabled_column": False,
|
||||
"broadcast_history_media_fields": False,
|
||||
"subscription_duplicates": False,
|
||||
"subscription_conversions_table": False
|
||||
"subscription_conversions_table": False,
|
||||
"promo_groups_table": False,
|
||||
"users_promo_group_column": False
|
||||
}
|
||||
|
||||
status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup')
|
||||
@@ -768,8 +1145,10 @@ async def check_migration_status():
|
||||
status["user_messages_table"] = await check_table_exists('user_messages')
|
||||
status["welcome_texts_table"] = await check_table_exists('welcome_texts')
|
||||
status["subscription_conversions_table"] = await check_table_exists('subscription_conversions')
|
||||
|
||||
status["promo_groups_table"] = await check_table_exists('promo_groups')
|
||||
|
||||
status["welcome_texts_is_enabled_column"] = await check_column_exists('welcome_texts', 'is_enabled')
|
||||
status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id')
|
||||
|
||||
media_fields_exist = (
|
||||
await check_column_exists('broadcast_history', 'has_media') and
|
||||
@@ -797,9 +1176,11 @@ async def check_migration_status():
|
||||
"user_messages_table": "Таблица пользовательских сообщений",
|
||||
"welcome_texts_table": "Таблица приветственных текстов",
|
||||
"welcome_texts_is_enabled_column": "Поле is_enabled в welcome_texts",
|
||||
"broadcast_history_media_fields": "Медиа поля в broadcast_history",
|
||||
"broadcast_history_media_fields": "Медиа поля в broadcast_history",
|
||||
"subscription_conversions_table": "Таблица конверсий подписок",
|
||||
"subscription_duplicates": "Отсутствие дубликатов подписок"
|
||||
"subscription_duplicates": "Отсутствие дубликатов подписок",
|
||||
"promo_groups_table": "Таблица промо-групп",
|
||||
"users_promo_group_column": "Колонка promo_group_id у пользователей"
|
||||
}
|
||||
|
||||
for check_key, check_status in status.items():
|
||||
|
||||
@@ -0,0 +1,632 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.crud.promo_group import (
|
||||
get_promo_groups_with_counts,
|
||||
get_promo_group_by_id,
|
||||
create_promo_group,
|
||||
update_promo_group,
|
||||
delete_promo_group,
|
||||
get_promo_group_members,
|
||||
count_promo_group_members,
|
||||
)
|
||||
from app.database.models import PromoGroup
|
||||
from app.localization.texts import get_texts
|
||||
from app.states import AdminStates
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.keyboards.admin import (
|
||||
get_admin_pagination_keyboard,
|
||||
get_confirmation_keyboard,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _format_discount_line(texts, group) -> str:
|
||||
return texts.t(
|
||||
"ADMIN_PROMO_GROUPS_DISCOUNTS",
|
||||
"Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
|
||||
).format(
|
||||
servers=group.server_discount_percent,
|
||||
traffic=group.traffic_discount_percent,
|
||||
devices=group.device_discount_percent,
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_promo_groups_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
groups = await get_promo_groups_with_counts(db)
|
||||
|
||||
total_members = sum(count for _, count in groups)
|
||||
header = texts.t("ADMIN_PROMO_GROUPS_TITLE", "💳 <b>Промогруппы</b>")
|
||||
|
||||
if groups:
|
||||
summary = texts.t(
|
||||
"ADMIN_PROMO_GROUPS_SUMMARY",
|
||||
"Всего групп: {count}\nВсего участников: {members}",
|
||||
).format(count=len(groups), members=total_members)
|
||||
lines = [header, "", summary, ""]
|
||||
|
||||
keyboard_rows = []
|
||||
for group, member_count in groups:
|
||||
default_suffix = (
|
||||
texts.t("ADMIN_PROMO_GROUPS_DEFAULT_LABEL", " (базовая)")
|
||||
if group.is_default
|
||||
else ""
|
||||
)
|
||||
lines.extend(
|
||||
[
|
||||
f"{'⭐' if group.is_default else '🎯'} <b>{group.name}</b>{default_suffix}",
|
||||
_format_discount_line(texts, group),
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT",
|
||||
"Участников: {count}",
|
||||
).format(count=member_count),
|
||||
"",
|
||||
]
|
||||
)
|
||||
keyboard_rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{'⭐' if group.is_default else '🎯'} {group.name}",
|
||||
callback_data=f"promo_group_manage_{group.id}",
|
||||
)
|
||||
])
|
||||
else:
|
||||
lines = [header, "", texts.t("ADMIN_PROMO_GROUPS_EMPTY", "Промогруппы не найдены.")]
|
||||
keyboard_rows = []
|
||||
|
||||
keyboard_rows.append(
|
||||
[types.InlineKeyboardButton(text="➕ Создать", callback_data="admin_promo_group_create")]
|
||||
)
|
||||
keyboard_rows.append(
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo")]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
"\n".join(line for line in lines if line is not None),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _get_group_or_alert(
|
||||
callback: types.CallbackQuery,
|
||||
db: AsyncSession,
|
||||
) -> Optional[PromoGroup]:
|
||||
group_id = int(callback.data.split("_")[-1])
|
||||
group = await get_promo_group_by_id(db, group_id)
|
||||
if not group:
|
||||
await callback.answer("❌ Промогруппа не найдена", show_alert=True)
|
||||
return None
|
||||
return group
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_promo_group_details(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
group = await _get_group_or_alert(callback, db)
|
||||
if not group:
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
member_count = await count_promo_group_members(db, group.id)
|
||||
|
||||
default_note = (
|
||||
"\n" + texts.t("ADMIN_PROMO_GROUP_DETAILS_DEFAULT", "Это базовая группа.")
|
||||
if group.is_default
|
||||
else ""
|
||||
)
|
||||
|
||||
text = "\n".join(
|
||||
[
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_DETAILS_TITLE",
|
||||
"💳 <b>Промогруппа:</b> {name}",
|
||||
).format(name=group.name),
|
||||
_format_discount_line(texts, group),
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS",
|
||||
"Участников: {count}",
|
||||
).format(count=member_count),
|
||||
default_note,
|
||||
]
|
||||
)
|
||||
|
||||
keyboard_rows = []
|
||||
if member_count > 0:
|
||||
keyboard_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_PROMO_GROUP_MEMBERS_BUTTON", "👥 Участники"),
|
||||
callback_data=f"promo_group_members_{group.id}_page_1",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_PROMO_GROUP_EDIT_BUTTON", "✏️ Изменить"),
|
||||
callback_data=f"promo_group_edit_{group.id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if not group.is_default:
|
||||
keyboard_rows.append(
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t("ADMIN_PROMO_GROUP_DELETE_BUTTON", "🗑️ Удалить"),
|
||||
callback_data=f"promo_group_delete_{group.id}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard_rows.append(
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_promo_groups")]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text.strip(),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def _validate_percent(value: str) -> int:
|
||||
percent = int(value)
|
||||
if percent < 0 or percent > 100:
|
||||
raise ValueError
|
||||
return percent
|
||||
|
||||
|
||||
async def _prompt_for_discount(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
prompt_key: str,
|
||||
default_text: str,
|
||||
):
|
||||
data = await state.get_data()
|
||||
texts = get_texts(data.get("language", "ru"))
|
||||
await message.answer(texts.t(prompt_key, default_text))
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_create_promo_group(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
await state.set_state(AdminStates.creating_promo_group_name)
|
||||
await state.update_data(language=db_user.language)
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT", "Введите название новой промогруппы:"),
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_promo_groups")]
|
||||
]
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def process_create_group_name(message: types.Message, state: FSMContext):
|
||||
name = message.text.strip()
|
||||
if not name:
|
||||
texts = get_texts((await state.get_data()).get("language", "ru"))
|
||||
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_NAME", "Название не может быть пустым."))
|
||||
return
|
||||
|
||||
await state.update_data(new_group_name=name)
|
||||
await state.set_state(AdminStates.creating_promo_group_traffic_discount)
|
||||
await _prompt_for_discount(
|
||||
message,
|
||||
state,
|
||||
"ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT",
|
||||
"Введите скидку на трафик (0-100):",
|
||||
)
|
||||
|
||||
|
||||
async def process_create_group_traffic(message: types.Message, state: FSMContext):
|
||||
texts = get_texts((await state.get_data()).get("language", "ru"))
|
||||
try:
|
||||
value = _validate_percent(message.text)
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
|
||||
return
|
||||
|
||||
await state.update_data(new_group_traffic=value)
|
||||
await state.set_state(AdminStates.creating_promo_group_server_discount)
|
||||
await _prompt_for_discount(
|
||||
message,
|
||||
state,
|
||||
"ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT",
|
||||
"Введите скидку на серверы (0-100):",
|
||||
)
|
||||
|
||||
|
||||
async def process_create_group_servers(message: types.Message, state: FSMContext):
|
||||
texts = get_texts((await state.get_data()).get("language", "ru"))
|
||||
try:
|
||||
value = _validate_percent(message.text)
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
|
||||
return
|
||||
|
||||
await state.update_data(new_group_servers=value)
|
||||
await state.set_state(AdminStates.creating_promo_group_device_discount)
|
||||
await _prompt_for_discount(
|
||||
message,
|
||||
state,
|
||||
"ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT",
|
||||
"Введите скидку на устройства (0-100):",
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_create_group_devices(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
data = await state.get_data()
|
||||
texts = get_texts(data.get("language", db_user.language))
|
||||
|
||||
try:
|
||||
devices_discount = _validate_percent(message.text)
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
|
||||
return
|
||||
|
||||
try:
|
||||
group = await create_promo_group(
|
||||
db,
|
||||
data["new_group_name"],
|
||||
traffic_discount_percent=data["new_group_traffic"],
|
||||
server_discount_percent=data["new_group_servers"],
|
||||
device_discount_percent=devices_discount,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось создать промогруппу: {e}")
|
||||
await message.answer(texts.ERROR)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
texts.t("ADMIN_PROMO_GROUP_CREATED", "Промогруппа «{name}» создана.").format(
|
||||
name=group.name
|
||||
),
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.t(
|
||||
"ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON",
|
||||
"↩️ К промогруппам",
|
||||
),
|
||||
callback_data="admin_promo_groups",
|
||||
)
|
||||
]
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_promo_group(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
state: FSMContext,
|
||||
db: AsyncSession,
|
||||
):
|
||||
group = await _get_group_or_alert(callback, db)
|
||||
if not group:
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
await state.set_state(AdminStates.editing_promo_group_name)
|
||||
await state.update_data(edit_group_id=group.id, language=db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT",
|
||||
"Введите новое название промогруппы (текущее: {name}):",
|
||||
).format(name=group.name),
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data=f"promo_group_manage_{group.id}")]
|
||||
]
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def process_edit_group_name(message: types.Message, state: FSMContext):
|
||||
name = message.text.strip()
|
||||
if not name:
|
||||
texts = get_texts((await state.get_data()).get("language", "ru"))
|
||||
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_NAME", "Название не может быть пустым."))
|
||||
return
|
||||
|
||||
await state.update_data(edit_group_name=name)
|
||||
await state.set_state(AdminStates.editing_promo_group_traffic_discount)
|
||||
await _prompt_for_discount(
|
||||
message,
|
||||
state,
|
||||
"ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT",
|
||||
"Введите новую скидку на трафик (0-100):",
|
||||
)
|
||||
|
||||
|
||||
async def process_edit_group_traffic(message: types.Message, state: FSMContext):
|
||||
texts = get_texts((await state.get_data()).get("language", "ru"))
|
||||
try:
|
||||
value = _validate_percent(message.text)
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
|
||||
return
|
||||
|
||||
await state.update_data(edit_group_traffic=value)
|
||||
await state.set_state(AdminStates.editing_promo_group_server_discount)
|
||||
await _prompt_for_discount(
|
||||
message,
|
||||
state,
|
||||
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT",
|
||||
"Введите новую скидку на серверы (0-100):",
|
||||
)
|
||||
|
||||
|
||||
async def process_edit_group_servers(message: types.Message, state: FSMContext):
|
||||
texts = get_texts((await state.get_data()).get("language", "ru"))
|
||||
try:
|
||||
value = _validate_percent(message.text)
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
|
||||
return
|
||||
|
||||
await state.update_data(edit_group_servers=value)
|
||||
await state.set_state(AdminStates.editing_promo_group_device_discount)
|
||||
await _prompt_for_discount(
|
||||
message,
|
||||
state,
|
||||
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT",
|
||||
"Введите новую скидку на устройства (0-100):",
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def process_edit_group_devices(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
data = await state.get_data()
|
||||
texts = get_texts(data.get("language", db_user.language))
|
||||
|
||||
try:
|
||||
devices_discount = _validate_percent(message.text)
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(texts.t("ADMIN_PROMO_GROUP_INVALID_PERCENT", "Введите число от 0 до 100."))
|
||||
return
|
||||
|
||||
group = await get_promo_group_by_id(db, data["edit_group_id"])
|
||||
if not group:
|
||||
await message.answer("❌ Промогруппа не найдена")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await update_promo_group(
|
||||
db,
|
||||
group,
|
||||
name=data["edit_group_name"],
|
||||
traffic_discount_percent=data["edit_group_traffic"],
|
||||
server_discount_percent=data["edit_group_servers"],
|
||||
device_discount_percent=devices_discount,
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
texts.t("ADMIN_PROMO_GROUP_UPDATED", "Промогруппа «{name}» обновлена.").format(name=group.name)
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_promo_group_members(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
parts = callback.data.split("_")
|
||||
group_id = int(parts[3])
|
||||
page = int(parts[-1])
|
||||
limit = 10
|
||||
offset = (page - 1) * limit
|
||||
|
||||
group = await get_promo_group_by_id(db, group_id)
|
||||
if not group:
|
||||
await callback.answer("❌ Промогруппа не найдена", show_alert=True)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
members = await get_promo_group_members(db, group_id, offset=offset, limit=limit)
|
||||
total_members = await count_promo_group_members(db, group_id)
|
||||
total_pages = max(1, (total_members + limit - 1) // limit)
|
||||
|
||||
title = texts.t(
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_TITLE",
|
||||
"👥 Участники группы {name}",
|
||||
).format(name=group.name)
|
||||
|
||||
if not members:
|
||||
body = texts.t("ADMIN_PROMO_GROUP_MEMBERS_EMPTY", "В этой группе пока нет участников.")
|
||||
else:
|
||||
lines = []
|
||||
for index, user in enumerate(members, start=offset + 1):
|
||||
username = f"@{user.username}" if user.username else "—"
|
||||
lines.append(
|
||||
f"{index}. {user.full_name} (ID {user.id}, {username}, TG {user.telegram_id})"
|
||||
)
|
||||
body = "\n".join(lines)
|
||||
|
||||
keyboard = []
|
||||
if total_pages > 1:
|
||||
pagination = get_admin_pagination_keyboard(
|
||||
page,
|
||||
total_pages,
|
||||
f"promo_group_members_{group_id}",
|
||||
f"promo_group_manage_{group_id}",
|
||||
db_user.language,
|
||||
)
|
||||
keyboard.extend(pagination.inline_keyboard)
|
||||
|
||||
keyboard.append(
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data=f"promo_group_manage_{group_id}")]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"{title}\n\n{body}",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def request_delete_promo_group(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
group = await _get_group_or_alert(callback, db)
|
||||
if not group:
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if group.is_default:
|
||||
await callback.answer(
|
||||
texts.t("ADMIN_PROMO_GROUP_DELETE_FORBIDDEN", "Базовую промогруппу нельзя удалить."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
confirm_text = texts.t(
|
||||
"ADMIN_PROMO_GROUP_DELETE_CONFIRM",
|
||||
"Удалить промогруппу «{name}»? Все пользователи будут переведены в базовую группу.",
|
||||
).format(name=group.name)
|
||||
|
||||
await callback.message.edit_text(
|
||||
confirm_text,
|
||||
reply_markup=get_confirmation_keyboard(
|
||||
confirm_action=f"promo_group_delete_confirm_{group.id}",
|
||||
cancel_action=f"promo_group_manage_{group.id}",
|
||||
language=db_user.language,
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_promo_group_confirmed(
|
||||
callback: types.CallbackQuery,
|
||||
db_user,
|
||||
db: AsyncSession,
|
||||
):
|
||||
group = await _get_group_or_alert(callback, db)
|
||||
if not group:
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
success = await delete_promo_group(db, group)
|
||||
if not success:
|
||||
await callback.answer(
|
||||
texts.t("ADMIN_PROMO_GROUP_DELETE_FORBIDDEN", "Базовую промогруппу нельзя удалить."),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_PROMO_GROUP_DELETED", "Промогруппа «{name}» удалена.").format(name=group.name),
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_promo_groups")]
|
||||
]
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_promo_groups_menu, F.data == "admin_promo_groups")
|
||||
dp.callback_query.register(show_promo_group_details, F.data.startswith("promo_group_manage_"))
|
||||
dp.callback_query.register(start_create_promo_group, F.data == "admin_promo_group_create")
|
||||
dp.callback_query.register(start_edit_promo_group, F.data.startswith("promo_group_edit_"))
|
||||
dp.callback_query.register(
|
||||
request_delete_promo_group,
|
||||
F.data.startswith("promo_group_delete_")
|
||||
& ~F.data.startswith("promo_group_delete_confirm_"),
|
||||
)
|
||||
dp.callback_query.register(
|
||||
delete_promo_group_confirmed,
|
||||
F.data.startswith("promo_group_delete_confirm_"),
|
||||
)
|
||||
dp.callback_query.register(
|
||||
show_promo_group_members,
|
||||
F.data.regexp(r"^promo_group_members_\d+_page_\d+$"),
|
||||
)
|
||||
|
||||
dp.message.register(process_create_group_name, AdminStates.creating_promo_group_name)
|
||||
dp.message.register(
|
||||
process_create_group_traffic,
|
||||
AdminStates.creating_promo_group_traffic_discount,
|
||||
)
|
||||
dp.message.register(
|
||||
process_create_group_servers,
|
||||
AdminStates.creating_promo_group_server_discount,
|
||||
)
|
||||
dp.message.register(
|
||||
process_create_group_devices,
|
||||
AdminStates.creating_promo_group_device_discount,
|
||||
)
|
||||
|
||||
dp.message.register(process_edit_group_name, AdminStates.editing_promo_group_name)
|
||||
dp.message.register(
|
||||
process_edit_group_traffic,
|
||||
AdminStates.editing_promo_group_traffic_discount,
|
||||
)
|
||||
dp.message.register(
|
||||
process_edit_group_servers,
|
||||
AdminStates.editing_promo_group_server_discount,
|
||||
)
|
||||
dp.message.register(
|
||||
process_edit_group_devices,
|
||||
AdminStates.editing_promo_group_device_discount,
|
||||
)
|
||||
+136
-3
@@ -12,10 +12,11 @@ from app.database.crud.user import get_user_by_id
|
||||
from app.keyboards.admin import (
|
||||
get_admin_users_keyboard, get_user_management_keyboard,
|
||||
get_admin_pagination_keyboard, get_confirmation_keyboard,
|
||||
get_admin_users_filters_keyboard
|
||||
get_admin_users_filters_keyboard, get_user_promo_group_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.user_service import UserService
|
||||
from app.database.crud.promo_group import get_promo_groups_with_counts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.utils.formatters import format_datetime, format_time_ago
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
@@ -820,7 +821,20 @@ async def show_user_management(
|
||||
"""
|
||||
else:
|
||||
text += "\n<b>Подписка:</b> Отсутствует"
|
||||
|
||||
|
||||
if user.promo_group:
|
||||
promo_group = user.promo_group
|
||||
text += f"""
|
||||
|
||||
<b>Промогруппа:</b>
|
||||
• Название: {promo_group.name}
|
||||
• Скидка на сервера: {promo_group.server_discount_percent}%
|
||||
• Скидка на трафик: {promo_group.traffic_discount_percent}%
|
||||
• Скидка на устройства: {promo_group.device_discount_percent}%
|
||||
"""
|
||||
else:
|
||||
text += "\n<b>Промогруппа:</b> Не назначена"
|
||||
|
||||
# Проверяем состояние, чтобы определить, откуда пришел пользователь
|
||||
current_state = await state.get_state()
|
||||
if current_state == AdminStates.viewing_user_from_balance_list:
|
||||
@@ -833,6 +847,115 @@ async def show_user_management(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _render_user_promo_group(
|
||||
message: types.Message,
|
||||
language: str,
|
||||
user: User,
|
||||
promo_groups: list
|
||||
) -> None:
|
||||
texts = get_texts(language)
|
||||
|
||||
current_group = user.promo_group
|
||||
|
||||
if current_group:
|
||||
current_line = texts.ADMIN_USER_PROMO_GROUP_CURRENT.format(name=current_group.name)
|
||||
discount_line = texts.ADMIN_USER_PROMO_GROUP_DISCOUNTS.format(
|
||||
servers=current_group.server_discount_percent,
|
||||
traffic=current_group.traffic_discount_percent,
|
||||
devices=current_group.device_discount_percent,
|
||||
)
|
||||
current_group_id = current_group.id
|
||||
else:
|
||||
current_line = texts.ADMIN_USER_PROMO_GROUP_CURRENT_NONE
|
||||
discount_line = texts.ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE
|
||||
current_group_id = None
|
||||
|
||||
text = (
|
||||
f"{texts.ADMIN_USER_PROMO_GROUP_TITLE}\n\n"
|
||||
f"{current_line}\n"
|
||||
f"{discount_line}\n\n"
|
||||
f"{texts.ADMIN_USER_PROMO_GROUP_SELECT}"
|
||||
)
|
||||
|
||||
await message.edit_text(
|
||||
text,
|
||||
reply_markup=get_user_promo_group_keyboard(
|
||||
promo_groups,
|
||||
user.id,
|
||||
current_group_id,
|
||||
language
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_user_promo_group(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
promo_groups = await get_promo_groups_with_counts(db)
|
||||
if not promo_groups:
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(texts.ADMIN_PROMO_GROUPS_EMPTY, show_alert=True)
|
||||
return
|
||||
|
||||
await _render_user_promo_group(callback.message, db_user.language, user, promo_groups)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def set_user_promo_group(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
|
||||
parts = callback.data.split('_')
|
||||
user_id = int(parts[-2])
|
||||
group_id = int(parts[-1])
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
await callback.answer("❌ Пользователь не найден", show_alert=True)
|
||||
return
|
||||
|
||||
if user.promo_group_id == group_id:
|
||||
await callback.answer(texts.ADMIN_USER_PROMO_GROUP_ALREADY, show_alert=True)
|
||||
return
|
||||
|
||||
user_service = UserService()
|
||||
success, updated_user, new_group = await user_service.update_user_promo_group(
|
||||
db,
|
||||
user_id,
|
||||
group_id
|
||||
)
|
||||
|
||||
if not success or not updated_user or not new_group:
|
||||
await callback.answer(texts.ADMIN_USER_PROMO_GROUP_ERROR, show_alert=True)
|
||||
return
|
||||
|
||||
promo_groups = await get_promo_groups_with_counts(db)
|
||||
|
||||
await _render_user_promo_group(callback.message, db_user.language, updated_user, promo_groups)
|
||||
await callback.answer(
|
||||
texts.ADMIN_USER_PROMO_GROUP_UPDATED.format(name=new_group.name),
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
@@ -2922,7 +3045,17 @@ def register_handlers(dp: Dispatcher):
|
||||
show_user_management,
|
||||
F.data.startswith("admin_user_manage_")
|
||||
)
|
||||
|
||||
|
||||
dp.callback_query.register(
|
||||
show_user_promo_group,
|
||||
F.data.startswith("admin_user_promo_group_") & ~F.data.contains("_set_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
set_user_promo_group,
|
||||
F.data.startswith("admin_user_promo_group_set_")
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
start_balance_edit,
|
||||
F.data.startswith("admin_user_balance_")
|
||||
|
||||
+537
-110
@@ -66,6 +66,25 @@ logger = logging.getLogger(__name__)
|
||||
TRAFFIC_PRICES = get_traffic_prices()
|
||||
|
||||
|
||||
def _apply_discount_to_monthly_component(
|
||||
amount_per_month: int,
|
||||
percent: int,
|
||||
months: int,
|
||||
) -> Dict[str, int]:
|
||||
from app.utils.pricing_utils import apply_percentage_discount
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
async def _prepare_subscription_summary(
|
||||
db_user: User,
|
||||
data: Dict[str, Any],
|
||||
@@ -75,6 +94,7 @@ async def _prepare_subscription_summary(
|
||||
calculate_months_from_days,
|
||||
format_period_description,
|
||||
validate_pricing_calculation,
|
||||
apply_percentage_discount,
|
||||
)
|
||||
|
||||
summary_data = dict(data)
|
||||
@@ -94,11 +114,21 @@ async def _prepare_subscription_summary(
|
||||
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
|
||||
final_traffic_gb = traffic_gb
|
||||
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
traffic_discount_percent = db_user.get_promo_discount(
|
||||
"traffic",
|
||||
summary_data['period_days'],
|
||||
)
|
||||
traffic_component = _apply_discount_to_monthly_component(
|
||||
traffic_price_per_month,
|
||||
traffic_discount_percent,
|
||||
months_in_period,
|
||||
)
|
||||
total_traffic_price = traffic_component["total"]
|
||||
|
||||
countries_price_per_month = 0
|
||||
selected_countries_names: List[str] = []
|
||||
selected_server_prices: List[int] = []
|
||||
server_monthly_prices: List[int] = []
|
||||
|
||||
selected_country_ids = set(summary_data.get('countries', []))
|
||||
for country in countries:
|
||||
@@ -106,25 +136,84 @@ async def _prepare_subscription_summary(
|
||||
server_price_per_month = country['price_kopeks']
|
||||
countries_price_per_month += server_price_per_month
|
||||
selected_countries_names.append(country['name'])
|
||||
selected_server_prices.append(server_price_per_month * months_in_period)
|
||||
server_monthly_prices.append(server_price_per_month)
|
||||
|
||||
total_countries_price = countries_price_per_month * months_in_period
|
||||
servers_discount_percent = db_user.get_promo_discount(
|
||||
"servers",
|
||||
summary_data['period_days'],
|
||||
)
|
||||
total_countries_price = 0
|
||||
total_servers_discount = 0
|
||||
discounted_servers_price_per_month = 0
|
||||
|
||||
for server_price_per_month in server_monthly_prices:
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
server_price_per_month,
|
||||
servers_discount_percent,
|
||||
)
|
||||
total_price_for_server = discounted_per_month * months_in_period
|
||||
total_discount_for_server = discount_per_month * months_in_period
|
||||
|
||||
discounted_servers_price_per_month += discounted_per_month
|
||||
total_countries_price += total_price_for_server
|
||||
total_servers_discount += total_discount_for_server
|
||||
selected_server_prices.append(total_price_for_server)
|
||||
|
||||
devices_selected = summary_data.get('devices', settings.DEFAULT_DEVICE_LIMIT)
|
||||
additional_devices = max(0, devices_selected - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
devices_discount_percent = db_user.get_promo_discount(
|
||||
"devices",
|
||||
summary_data['period_days'],
|
||||
)
|
||||
devices_component = _apply_discount_to_monthly_component(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
months_in_period,
|
||||
)
|
||||
total_devices_price = devices_component["total"]
|
||||
|
||||
total_price = base_price + total_traffic_price + total_countries_price + total_devices_price
|
||||
|
||||
monthly_additions = countries_price_per_month + devices_price_per_month + traffic_price_per_month
|
||||
is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, total_price)
|
||||
discounted_monthly_additions = (
|
||||
traffic_component["discounted_per_month"]
|
||||
+ discounted_servers_price_per_month
|
||||
+ devices_component["discounted_per_month"]
|
||||
)
|
||||
|
||||
is_valid = validate_pricing_calculation(
|
||||
base_price,
|
||||
discounted_monthly_additions,
|
||||
months_in_period,
|
||||
total_price,
|
||||
)
|
||||
|
||||
if not is_valid:
|
||||
raise ValueError("Subscription price calculation validation failed")
|
||||
|
||||
summary_data['total_price'] = total_price
|
||||
summary_data['server_prices_for_period'] = selected_server_prices
|
||||
summary_data['months_in_period'] = months_in_period
|
||||
summary_data['base_price'] = base_price
|
||||
summary_data['final_traffic_gb'] = final_traffic_gb
|
||||
summary_data['traffic_price_per_month'] = traffic_price_per_month
|
||||
summary_data['traffic_discount_percent'] = traffic_component["discount_percent"]
|
||||
summary_data['traffic_discount_total'] = traffic_component["discount_total"]
|
||||
summary_data['traffic_discounted_price_per_month'] = traffic_component["discounted_per_month"]
|
||||
summary_data['total_traffic_price'] = total_traffic_price
|
||||
summary_data['servers_price_per_month'] = countries_price_per_month
|
||||
summary_data['countries_price_per_month'] = countries_price_per_month
|
||||
summary_data['servers_discount_percent'] = servers_discount_percent
|
||||
summary_data['servers_discount_total'] = total_servers_discount
|
||||
summary_data['servers_discounted_price_per_month'] = discounted_servers_price_per_month
|
||||
summary_data['total_servers_price'] = total_countries_price
|
||||
summary_data['total_countries_price'] = total_countries_price
|
||||
summary_data['devices_price_per_month'] = devices_price_per_month
|
||||
summary_data['devices_discount_percent'] = devices_component["discount_percent"]
|
||||
summary_data['devices_discount_total'] = devices_component["discount_total"]
|
||||
summary_data['devices_discounted_price_per_month'] = devices_component["discounted_per_month"]
|
||||
summary_data['total_devices_price'] = total_devices_price
|
||||
summary_data['discounted_monthly_additions'] = discounted_monthly_additions
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
if final_traffic_gb == 0:
|
||||
@@ -140,17 +229,38 @@ async def _prepare_subscription_summary(
|
||||
details_lines = [f"- Базовый период: {texts.format_price(base_price)}"]
|
||||
|
||||
if total_traffic_price > 0:
|
||||
details_lines.append(
|
||||
f"- Трафик: {texts.format_price(traffic_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_traffic_price)}"
|
||||
traffic_line = (
|
||||
f"- Трафик: {texts.format_price(traffic_price_per_month)}/мес × {months_in_period}"
|
||||
f" = {texts.format_price(total_traffic_price)}"
|
||||
)
|
||||
if traffic_component["discount_total"] > 0:
|
||||
traffic_line += (
|
||||
f" (скидка {traffic_component['discount_percent']}%:"
|
||||
f" -{texts.format_price(traffic_component['discount_total'])})"
|
||||
)
|
||||
details_lines.append(traffic_line)
|
||||
if total_countries_price > 0:
|
||||
details_lines.append(
|
||||
f"- Серверы: {texts.format_price(countries_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_countries_price)}"
|
||||
servers_line = (
|
||||
f"- Серверы: {texts.format_price(countries_price_per_month)}/мес × {months_in_period}"
|
||||
f" = {texts.format_price(total_countries_price)}"
|
||||
)
|
||||
if total_servers_discount > 0:
|
||||
servers_line += (
|
||||
f" (скидка {servers_discount_percent}%:"
|
||||
f" -{texts.format_price(total_servers_discount)})"
|
||||
)
|
||||
details_lines.append(servers_line)
|
||||
if total_devices_price > 0:
|
||||
details_lines.append(
|
||||
f"- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period} = {texts.format_price(total_devices_price)}"
|
||||
devices_line = (
|
||||
f"- Доп. устройства: {texts.format_price(devices_price_per_month)}/мес × {months_in_period}"
|
||||
f" = {texts.format_price(total_devices_price)}"
|
||||
)
|
||||
if devices_component["discount_total"] > 0:
|
||||
devices_line += (
|
||||
f" (скидка {devices_component['discount_percent']}%:"
|
||||
f" -{texts.format_price(devices_component['discount_total'])})"
|
||||
)
|
||||
details_lines.append(devices_line)
|
||||
|
||||
details_text = "\n".join(details_lines)
|
||||
|
||||
@@ -168,6 +278,99 @@ async def _prepare_subscription_summary(
|
||||
|
||||
return summary_text, summary_data
|
||||
|
||||
|
||||
def _build_promo_group_discount_text(
|
||||
db_user: User,
|
||||
periods: Optional[List[int]] = None,
|
||||
texts=None,
|
||||
) -> str:
|
||||
promo_group = getattr(db_user, "promo_group", None)
|
||||
|
||||
if not promo_group:
|
||||
return ""
|
||||
|
||||
if texts is None:
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
service_lines: List[str] = []
|
||||
|
||||
if promo_group.server_discount_percent > 0:
|
||||
service_lines.append(
|
||||
texts.PROMO_GROUP_DISCOUNT_SERVERS.format(
|
||||
percent=promo_group.server_discount_percent
|
||||
)
|
||||
)
|
||||
|
||||
if promo_group.traffic_discount_percent > 0:
|
||||
service_lines.append(
|
||||
texts.PROMO_GROUP_DISCOUNT_TRAFFIC.format(
|
||||
percent=promo_group.traffic_discount_percent
|
||||
)
|
||||
)
|
||||
|
||||
if promo_group.device_discount_percent > 0:
|
||||
service_lines.append(
|
||||
texts.PROMO_GROUP_DISCOUNT_DEVICES.format(
|
||||
percent=promo_group.device_discount_percent
|
||||
)
|
||||
)
|
||||
|
||||
period_lines: List[str] = []
|
||||
|
||||
if (
|
||||
promo_group.is_default
|
||||
and periods
|
||||
and settings.is_base_promo_group_period_discount_enabled()
|
||||
):
|
||||
discounts = settings.get_base_promo_group_period_discounts()
|
||||
|
||||
for period_days in periods:
|
||||
percent = discounts.get(period_days, 0)
|
||||
|
||||
if percent <= 0:
|
||||
continue
|
||||
|
||||
period_display = format_period_description(period_days, db_user.language)
|
||||
period_lines.append(
|
||||
texts.PROMO_GROUP_PERIOD_DISCOUNT_ITEM.format(
|
||||
period=period_display,
|
||||
percent=percent,
|
||||
)
|
||||
)
|
||||
|
||||
if not service_lines and not period_lines:
|
||||
return ""
|
||||
|
||||
lines: List[str] = [texts.PROMO_GROUP_DISCOUNTS_HEADER]
|
||||
|
||||
if service_lines:
|
||||
lines.extend(service_lines)
|
||||
|
||||
if period_lines:
|
||||
if service_lines:
|
||||
lines.append("")
|
||||
|
||||
lines.append(texts.PROMO_GROUP_PERIOD_DISCOUNTS_HEADER)
|
||||
lines.extend(period_lines)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_subscription_period_prompt(db_user: User, texts) -> str:
|
||||
base_text = texts.BUY_SUBSCRIPTION_START.rstrip()
|
||||
|
||||
promo_text = _build_promo_group_discount_text(
|
||||
db_user,
|
||||
settings.get_available_subscription_periods(),
|
||||
texts=texts,
|
||||
)
|
||||
|
||||
if not promo_text:
|
||||
return f"{base_text}\n"
|
||||
|
||||
return f"{base_text}\n\n{promo_text}\n"
|
||||
|
||||
|
||||
async def show_subscription_info(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
@@ -664,9 +867,9 @@ async def start_subscription_purchase(
|
||||
db_user: User
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.BUY_SUBSCRIPTION_START,
|
||||
_build_subscription_period_prompt(db_user, texts),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
@@ -1601,15 +1804,30 @@ async def handle_extend_subscription(
|
||||
servers_price_per_month, _ = await subscription_service.get_countries_price_by_uuids(
|
||||
subscription.connected_squads, db
|
||||
)
|
||||
total_servers_price = servers_price_per_month * months_in_period
|
||||
|
||||
servers_discount_percent = db_user.get_promo_discount(
|
||||
"servers",
|
||||
days,
|
||||
)
|
||||
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
|
||||
total_servers_price = (servers_price_per_month - servers_discount_per_month) * months_in_period
|
||||
|
||||
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
devices_discount_percent = db_user.get_promo_discount(
|
||||
"devices",
|
||||
days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
total_devices_price = (devices_price_per_month - devices_discount_per_month) * months_in_period
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
traffic_discount_percent = db_user.get_promo_discount(
|
||||
"traffic",
|
||||
days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
total_traffic_price = (traffic_price_per_month - traffic_discount_per_month) * months_in_period
|
||||
|
||||
price = base_price + total_servers_price + total_devices_price + total_traffic_price
|
||||
renewal_prices[days] = price
|
||||
|
||||
@@ -1622,14 +1840,20 @@ async def handle_extend_subscription(
|
||||
return
|
||||
|
||||
prices_text = ""
|
||||
|
||||
|
||||
for days in available_periods:
|
||||
if days in renewal_prices:
|
||||
period_display = format_period_description(days, db_user.language)
|
||||
prices_text += f"📅 {period_display} - {texts.format_price(renewal_prices[days])}\n"
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"⏰ Продление подписки\n\n"
|
||||
|
||||
promo_discounts_text = _build_promo_group_discount_text(
|
||||
db_user,
|
||||
available_periods,
|
||||
texts=texts,
|
||||
)
|
||||
|
||||
message_text = (
|
||||
"⏰ Продление подписки\n\n"
|
||||
f"Осталось дней: {subscription.days_left}\n\n"
|
||||
f"<b>Ваша текущая конфигурация:</b>\n"
|
||||
f"🌍 Серверов: {len(subscription.connected_squads)}\n"
|
||||
@@ -1637,7 +1861,15 @@ async def handle_extend_subscription(
|
||||
f"📱 Устройств: {subscription.device_limit}\n\n"
|
||||
f"<b>Выберите период продления:</b>\n"
|
||||
f"{prices_text.rstrip()}\n\n"
|
||||
f"💡 <i>Цена включает все ваши текущие серверы и настройки</i>",
|
||||
)
|
||||
|
||||
if promo_discounts_text:
|
||||
message_text += f"{promo_discounts_text}\n\n"
|
||||
|
||||
message_text += "💡 <i>Цена включает все ваши текущие серверы и настройки</i>"
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=get_extend_subscription_keyboard_with_prices(db_user.language, renewal_prices),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
@@ -1783,62 +2015,121 @@ async def confirm_extend_subscription(
|
||||
):
|
||||
from app.utils.pricing_utils import calculate_months_from_days, validate_pricing_calculation
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
|
||||
|
||||
days = int(callback.data.split('_')[2])
|
||||
texts = get_texts(db_user.language)
|
||||
subscription = db_user.subscription
|
||||
|
||||
|
||||
if not subscription:
|
||||
await callback.answer("⚠ У вас нет активной подписки", show_alert=True)
|
||||
return
|
||||
|
||||
|
||||
months_in_period = calculate_months_from_days(days)
|
||||
|
||||
old_end_date = subscription.end_date
|
||||
|
||||
server_uuid_prices: Dict[str, int] = {}
|
||||
|
||||
try:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
|
||||
base_price = PERIOD_PRICES.get(days, 0)
|
||||
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
servers_price_per_month, _ = await subscription_service.get_countries_price_by_uuids(
|
||||
servers_price_per_month, per_server_monthly_prices = await subscription_service.get_countries_price_by_uuids(
|
||||
subscription.connected_squads, db
|
||||
)
|
||||
total_servers_price = servers_price_per_month * months_in_period
|
||||
|
||||
servers_discount_percent = db_user.get_promo_discount(
|
||||
"servers",
|
||||
days,
|
||||
)
|
||||
total_servers_price = 0
|
||||
total_servers_discount = 0
|
||||
|
||||
for squad_uuid, server_monthly_price in zip(subscription.connected_squads, per_server_monthly_prices):
|
||||
discount_per_month = server_monthly_price * servers_discount_percent // 100
|
||||
discounted_per_month = server_monthly_price - discount_per_month
|
||||
total_servers_price += discounted_per_month * months_in_period
|
||||
total_servers_discount += discount_per_month * months_in_period
|
||||
server_uuid_prices[squad_uuid] = discounted_per_month * months_in_period
|
||||
|
||||
discounted_servers_price_per_month = servers_price_per_month - (
|
||||
servers_price_per_month * servers_discount_percent // 100
|
||||
)
|
||||
|
||||
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
devices_discount_percent = db_user.get_promo_discount(
|
||||
"devices",
|
||||
days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_price_per_month = devices_price_per_month - devices_discount_per_month
|
||||
total_devices_price = discounted_devices_price_per_month * months_in_period
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
traffic_discount_percent = db_user.get_promo_discount(
|
||||
"traffic",
|
||||
days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_price_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
total_traffic_price = discounted_traffic_price_per_month * months_in_period
|
||||
|
||||
price = base_price + total_servers_price + total_devices_price + total_traffic_price
|
||||
|
||||
monthly_additions = servers_price_per_month + devices_price_per_month + traffic_price_per_month
|
||||
|
||||
monthly_additions = (
|
||||
discounted_servers_price_per_month
|
||||
+ discounted_devices_price_per_month
|
||||
+ discounted_traffic_price_per_month
|
||||
)
|
||||
is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, price)
|
||||
|
||||
|
||||
if not is_valid:
|
||||
logger.error(f"Ошибка в расчете цены продления для пользователя {db_user.telegram_id}")
|
||||
await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True)
|
||||
return
|
||||
|
||||
|
||||
logger.info(f"💰 Расчет продления подписки {subscription.id} на {days} дней ({months_in_period} мес):")
|
||||
logger.info(f" 📅 Период {days} дней: {base_price/100}₽")
|
||||
if total_servers_price > 0:
|
||||
logger.info(f" 🌐 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period} = {total_servers_price/100}₽")
|
||||
logger.info(
|
||||
f" 🌐 Серверы: {servers_price_per_month/100}₽/мес × {months_in_period}"
|
||||
f" = {total_servers_price/100}₽"
|
||||
+ (
|
||||
f" (скидка {servers_discount_percent}%:"
|
||||
f" -{total_servers_discount/100}₽)"
|
||||
if total_servers_discount > 0
|
||||
else ""
|
||||
)
|
||||
)
|
||||
if total_devices_price > 0:
|
||||
logger.info(f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}₽")
|
||||
logger.info(
|
||||
f" 📱 Устройства: {devices_price_per_month/100}₽/мес × {months_in_period}"
|
||||
f" = {total_devices_price/100}₽"
|
||||
+ (
|
||||
f" (скидка {devices_discount_percent}%:"
|
||||
f" -{devices_discount_per_month * months_in_period/100}₽)"
|
||||
if devices_discount_percent > 0 and devices_discount_per_month > 0
|
||||
else ""
|
||||
)
|
||||
)
|
||||
if total_traffic_price > 0:
|
||||
logger.info(f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}₽")
|
||||
logger.info(
|
||||
f" 📊 Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period}"
|
||||
f" = {total_traffic_price/100}₽"
|
||||
+ (
|
||||
f" (скидка {traffic_discount_percent}%:"
|
||||
f" -{traffic_discount_per_month * months_in_period/100}₽)"
|
||||
if traffic_discount_percent > 0 and traffic_discount_per_month > 0
|
||||
else ""
|
||||
)
|
||||
)
|
||||
logger.info(f" 💎 ИТОГО: {price/100}₽")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"⚠ ОШИБКА РАСЧЕТА ЦЕНЫ: {e}")
|
||||
await callback.answer("⚠ Ошибка расчета стоимости", show_alert=True)
|
||||
return
|
||||
|
||||
|
||||
if db_user.balance_kopeks < price:
|
||||
missing_kopeks = price - db_user.balance_kopeks
|
||||
await callback.message.edit_text(
|
||||
@@ -1847,48 +2138,59 @@ async def confirm_extend_subscription(
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
|
||||
try:
|
||||
success = await subtract_user_balance(
|
||||
db, db_user, price,
|
||||
f"Продление подписки на {days} дней"
|
||||
)
|
||||
|
||||
|
||||
if not success:
|
||||
await callback.answer("⚠ Ошибка списания средств", show_alert=True)
|
||||
return
|
||||
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
|
||||
if subscription.end_date > current_time:
|
||||
subscription.end_date = subscription.end_date + timedelta(days=days)
|
||||
else:
|
||||
subscription.end_date = current_time + timedelta(days=days)
|
||||
|
||||
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.updated_at = current_time
|
||||
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
await db.refresh(db_user)
|
||||
|
||||
|
||||
from app.database.crud.server_squad import get_server_ids_by_uuids
|
||||
from app.database.crud.subscription import add_subscription_servers
|
||||
|
||||
|
||||
server_ids = await get_server_ids_by_uuids(db, subscription.connected_squads)
|
||||
if server_ids:
|
||||
server_prices_for_period = [total_servers_price // len(server_ids)] * len(server_ids)
|
||||
from sqlalchemy import select
|
||||
from app.database.models import ServerSquad
|
||||
|
||||
result = await db.execute(
|
||||
select(ServerSquad.id, ServerSquad.squad_uuid).where(ServerSquad.id.in_(server_ids))
|
||||
)
|
||||
id_to_uuid = {row.id: row.squad_uuid for row in result}
|
||||
default_price = total_servers_price // len(server_ids) if server_ids else 0
|
||||
server_prices_for_period = [
|
||||
server_uuid_prices.get(id_to_uuid.get(server_id, ""), default_price)
|
||||
for server_id in server_ids
|
||||
]
|
||||
await add_subscription_servers(db, subscription, server_ids, server_prices_for_period)
|
||||
|
||||
|
||||
try:
|
||||
remnawave_result = await subscription_service.update_remnawave_user(db, subscription)
|
||||
if remnawave_result:
|
||||
logger.info(f"✅ RemnaWave обновлен успешно")
|
||||
logger.info("✅ RemnaWave обновлен успешно")
|
||||
else:
|
||||
logger.error(f"⚠ ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE")
|
||||
logger.error("⚠ ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE")
|
||||
except Exception as e:
|
||||
logger.error(f"⚠ ИСКЛЮЧЕНИЕ ПРИ ОБНОВЛЕНИИ REMNAWAVE: {e}")
|
||||
|
||||
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
@@ -1896,7 +2198,7 @@ async def confirm_extend_subscription(
|
||||
amount_kopeks=price,
|
||||
description=f"Продление подписки на {days} дней ({months_in_period} мес)"
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
notification_service = AdminNotificationService(callback.bot)
|
||||
await notification_service.send_subscription_extension_notification(
|
||||
@@ -1904,27 +2206,31 @@ async def confirm_extend_subscription(
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки уведомления о продлении: {e}")
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"✅ Подписка успешно продлена!\n\n"
|
||||
|
||||
success_message = (
|
||||
"✅ Подписка успешно продлена!\n\n"
|
||||
f"⏰ Добавлено: {days} дней\n"
|
||||
f"Действует до: {subscription.end_date.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
f"💰 Списано: {texts.format_price(price)}",
|
||||
f"💰 Списано: {texts.format_price(price)}"
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
success_message,
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
|
||||
logger.info(f"✅ Пользователь {db_user.telegram_id} продлил подписку на {days} дней за {price/100}₽")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"⚠ КРИТИЧЕСКАЯ ОШИБКА ПРОДЛЕНИЯ: {e}")
|
||||
import traceback
|
||||
logger.error(f"TRACEBACK: {traceback.format_exc()}")
|
||||
|
||||
|
||||
await callback.message.edit_text(
|
||||
"⚠ Произошла ошибка при продлении подписки. Обратитесь в поддержку.",
|
||||
reply_markup=get_back_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -2365,38 +2671,132 @@ async def confirm_purchase(
|
||||
|
||||
countries = await _get_available_countries()
|
||||
|
||||
months_in_period = calculate_months_from_days(data['period_days'])
|
||||
|
||||
base_price = PERIOD_PRICES[data['period_days']]
|
||||
|
||||
countries_price_per_month = 0
|
||||
server_prices = []
|
||||
for country in countries:
|
||||
if country['uuid'] in data['countries']:
|
||||
server_price_per_month = country['price_kopeks']
|
||||
server_price_total = server_price_per_month * months_in_period
|
||||
countries_price_per_month += server_price_per_month
|
||||
server_prices.append(server_price_total)
|
||||
|
||||
total_countries_price = countries_price_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, data['devices'] - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_price_per_month = settings.get_traffic_price(settings.get_fixed_traffic_limit())
|
||||
final_traffic_gb = settings.get_fixed_traffic_limit()
|
||||
months_in_period = data.get(
|
||||
'months_in_period', calculate_months_from_days(data['period_days'])
|
||||
)
|
||||
|
||||
base_price = data.get('base_price', PERIOD_PRICES[data['period_days']])
|
||||
server_prices = data.get('server_prices_for_period', [])
|
||||
|
||||
if not server_prices:
|
||||
countries_price_per_month = 0
|
||||
per_month_prices: List[int] = []
|
||||
for country in countries:
|
||||
if country['uuid'] in data['countries']:
|
||||
server_price_per_month = country['price_kopeks']
|
||||
countries_price_per_month += server_price_per_month
|
||||
per_month_prices.append(server_price_per_month)
|
||||
|
||||
servers_discount_percent = db_user.get_promo_discount(
|
||||
"servers",
|
||||
data['period_days'],
|
||||
)
|
||||
total_servers_price = 0
|
||||
total_servers_discount = 0
|
||||
discounted_servers_price_per_month = 0
|
||||
server_prices = []
|
||||
|
||||
from app.utils.pricing_utils import apply_percentage_discount
|
||||
|
||||
for server_price_per_month in per_month_prices:
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
server_price_per_month,
|
||||
servers_discount_percent,
|
||||
)
|
||||
total_price_for_server = discounted_per_month * months_in_period
|
||||
total_discount_for_server = discount_per_month * months_in_period
|
||||
|
||||
discounted_servers_price_per_month += discounted_per_month
|
||||
total_servers_price += total_price_for_server
|
||||
total_servers_discount += total_discount_for_server
|
||||
server_prices.append(total_price_for_server)
|
||||
|
||||
total_countries_price = total_servers_price
|
||||
else:
|
||||
traffic_price_per_month = settings.get_traffic_price(data['traffic_gb'])
|
||||
final_traffic_gb = data['traffic_gb']
|
||||
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
final_price = base_price + total_traffic_price + total_countries_price + total_devices_price
|
||||
|
||||
monthly_additions = countries_price_per_month + devices_price_per_month + traffic_price_per_month
|
||||
is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, final_price)
|
||||
total_countries_price = data.get('total_servers_price', sum(server_prices))
|
||||
countries_price_per_month = data.get('servers_price_per_month', 0)
|
||||
discounted_servers_price_per_month = data.get('servers_discounted_price_per_month', countries_price_per_month)
|
||||
total_servers_discount = data.get('servers_discount_total', 0)
|
||||
servers_discount_percent = data.get('servers_discount_percent', 0)
|
||||
|
||||
additional_devices = max(0, data['devices'] - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = data.get(
|
||||
'devices_price_per_month', additional_devices * settings.PRICE_PER_DEVICE
|
||||
)
|
||||
if 'devices_discount_percent' in data:
|
||||
devices_discount_percent = data.get('devices_discount_percent', 0)
|
||||
discounted_devices_price_per_month = data.get(
|
||||
'devices_discounted_price_per_month', devices_price_per_month
|
||||
)
|
||||
devices_discount_total = data.get('devices_discount_total', 0)
|
||||
total_devices_price = data.get(
|
||||
'total_devices_price', discounted_devices_price_per_month * months_in_period
|
||||
)
|
||||
else:
|
||||
devices_discount_percent = db_user.get_promo_discount(
|
||||
"devices",
|
||||
data['period_days'],
|
||||
)
|
||||
from app.utils.pricing_utils import apply_percentage_discount
|
||||
|
||||
discounted_devices_price_per_month, discount_per_month = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
)
|
||||
devices_discount_total = discount_per_month * months_in_period
|
||||
total_devices_price = discounted_devices_price_per_month * months_in_period
|
||||
|
||||
if settings.is_traffic_fixed():
|
||||
final_traffic_gb = settings.get_fixed_traffic_limit()
|
||||
traffic_price_per_month = data.get(
|
||||
'traffic_price_per_month', settings.get_traffic_price(final_traffic_gb)
|
||||
)
|
||||
else:
|
||||
final_traffic_gb = data.get('final_traffic_gb', data.get('traffic_gb'))
|
||||
traffic_price_per_month = data.get(
|
||||
'traffic_price_per_month', settings.get_traffic_price(data['traffic_gb'])
|
||||
)
|
||||
|
||||
if 'traffic_discount_percent' in data:
|
||||
traffic_discount_percent = data.get('traffic_discount_percent', 0)
|
||||
discounted_traffic_price_per_month = data.get(
|
||||
'traffic_discounted_price_per_month', traffic_price_per_month
|
||||
)
|
||||
traffic_discount_total = data.get('traffic_discount_total', 0)
|
||||
total_traffic_price = data.get(
|
||||
'total_traffic_price', discounted_traffic_price_per_month * months_in_period
|
||||
)
|
||||
else:
|
||||
traffic_discount_percent = db_user.get_promo_discount(
|
||||
"traffic",
|
||||
data['period_days'],
|
||||
)
|
||||
from app.utils.pricing_utils import apply_percentage_discount
|
||||
|
||||
discounted_traffic_price_per_month, discount_per_month = apply_percentage_discount(
|
||||
traffic_price_per_month,
|
||||
traffic_discount_percent,
|
||||
)
|
||||
traffic_discount_total = discount_per_month * months_in_period
|
||||
total_traffic_price = discounted_traffic_price_per_month * months_in_period
|
||||
|
||||
total_servers_price = data.get('total_servers_price', total_countries_price)
|
||||
|
||||
final_price = data['total_price']
|
||||
|
||||
discounted_monthly_additions = data.get(
|
||||
'discounted_monthly_additions',
|
||||
discounted_traffic_price_per_month
|
||||
+ discounted_servers_price_per_month
|
||||
+ discounted_devices_price_per_month,
|
||||
)
|
||||
|
||||
is_valid = validate_pricing_calculation(
|
||||
base_price,
|
||||
discounted_monthly_additions,
|
||||
months_in_period,
|
||||
final_price,
|
||||
)
|
||||
|
||||
if not is_valid:
|
||||
logger.error(f"Ошибка в расчете цены подписки для пользователя {db_user.telegram_id}")
|
||||
@@ -2406,11 +2806,38 @@ async def confirm_purchase(
|
||||
logger.info(f"Расчет покупки подписки на {data['period_days']} дней ({months_in_period} мес):")
|
||||
logger.info(f" Период: {base_price/100}₽")
|
||||
if total_traffic_price > 0:
|
||||
logger.info(f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period} = {total_traffic_price/100}₽")
|
||||
if total_countries_price > 0:
|
||||
logger.info(f" Серверы: {countries_price_per_month/100}₽/мес × {months_in_period} = {total_countries_price/100}₽")
|
||||
message = (
|
||||
f" Трафик: {traffic_price_per_month/100}₽/мес × {months_in_period}"
|
||||
f" = {total_traffic_price/100}₽"
|
||||
)
|
||||
if traffic_discount_total > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%:"
|
||||
f" -{traffic_discount_total/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_servers_price > 0:
|
||||
message = (
|
||||
f" Серверы: {countries_price_per_month/100}₽/мес × {months_in_period}"
|
||||
f" = {total_servers_price/100}₽"
|
||||
)
|
||||
if total_servers_discount > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%:"
|
||||
f" -{total_servers_discount/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_devices_price > 0:
|
||||
logger.info(f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period} = {total_devices_price/100}₽")
|
||||
message = (
|
||||
f" Устройства: {devices_price_per_month/100}₽/мес × {months_in_period}"
|
||||
f" = {total_devices_price/100}₽"
|
||||
)
|
||||
if devices_discount_total > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%:"
|
||||
f" -{devices_discount_total/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" ИТОГО: {final_price/100}₽")
|
||||
|
||||
if db_user.balance_kopeks < final_price:
|
||||
@@ -2910,11 +3337,11 @@ async def handle_subscription_config_back(
|
||||
|
||||
if current_state == SubscriptionStates.selecting_traffic.state:
|
||||
await callback.message.edit_text(
|
||||
texts.BUY_SUBSCRIPTION_START,
|
||||
_build_subscription_period_prompt(db_user, texts),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_period)
|
||||
|
||||
|
||||
elif current_state == SubscriptionStates.selecting_countries.state:
|
||||
if settings.is_traffic_selectable():
|
||||
await callback.message.edit_text(
|
||||
@@ -2924,11 +3351,11 @@ async def handle_subscription_config_back(
|
||||
await state.set_state(SubscriptionStates.selecting_traffic)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
texts.BUY_SUBSCRIPTION_START,
|
||||
_build_subscription_period_prompt(db_user, texts),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_period)
|
||||
|
||||
|
||||
elif current_state == SubscriptionStates.selecting_devices.state:
|
||||
if await _should_show_countries_management():
|
||||
countries = await _get_available_countries()
|
||||
@@ -2948,7 +3375,7 @@ async def handle_subscription_config_back(
|
||||
await state.set_state(SubscriptionStates.selecting_traffic)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
texts.BUY_SUBSCRIPTION_START,
|
||||
_build_subscription_period_prompt(db_user, texts),
|
||||
reply_markup=get_subscription_period_keyboard(db_user.language)
|
||||
)
|
||||
await state.set_state(SubscriptionStates.selecting_period)
|
||||
|
||||
+43
-2
@@ -1,4 +1,4 @@
|
||||
from typing import List, Optional
|
||||
from typing import List, Optional, Tuple, Any
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
from app.localization.texts import get_texts
|
||||
@@ -45,6 +45,9 @@ def get_admin_promo_submenu_keyboard(language: str = "ru") -> InlineKeyboardMark
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_CAMPAIGNS, callback_data="admin_campaigns")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_PROMO_GROUPS, callback_data="admin_promo_groups")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
|
||||
]
|
||||
@@ -390,11 +393,19 @@ def get_admin_statistics_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
|
||||
|
||||
|
||||
def get_user_management_keyboard(user_id: int, user_status: str, language: str = "ru", back_callback: str = "admin_users_list") -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
keyboard = [
|
||||
[
|
||||
InlineKeyboardButton(text="💰 Баланс", callback_data=f"admin_user_balance_{user_id}"),
|
||||
InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.ADMIN_USER_PROMO_GROUP_BUTTON,
|
||||
callback_data=f"admin_user_promo_group_{user_id}"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data=f"admin_user_statistics_{user_id}")
|
||||
],
|
||||
@@ -421,7 +432,37 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data=back_callback)
|
||||
])
|
||||
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_user_promo_group_keyboard(
|
||||
promo_groups: List[Tuple[Any, int]],
|
||||
user_id: int,
|
||||
current_group_id: Optional[int],
|
||||
language: str = "ru"
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
|
||||
keyboard: List[List[InlineKeyboardButton]] = []
|
||||
|
||||
for group, members_count in promo_groups:
|
||||
prefix = "✅" if current_group_id is not None and group.id == current_group_id else "👥"
|
||||
count_text = f" ({members_count})" if members_count else ""
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{prefix} {group.name}{count_text}",
|
||||
callback_data=f"admin_user_promo_group_set_{user_id}_{group.id}"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.ADMIN_USER_PROMO_GROUP_BACK,
|
||||
callback_data=f"admin_user_manage_{user_id}"
|
||||
)
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
|
||||
@@ -123,6 +123,48 @@
|
||||
"ADMIN_REMNAWAVE": "🖥️ Remnawave",
|
||||
"ADMIN_RULES": "📋 Rules",
|
||||
"ADMIN_STATISTICS": "📊 Statistics",
|
||||
"ADMIN_PROMO_GROUPS": "💳 Promo groups",
|
||||
"ADMIN_PROMO_GROUPS_TITLE": "💳 <b>Promo groups</b>",
|
||||
"ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}",
|
||||
"ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%",
|
||||
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)",
|
||||
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}",
|
||||
"ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.",
|
||||
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Promo group",
|
||||
"ADMIN_USER_PROMO_GROUP_TITLE": "👥 <b>User promo group</b>",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT": "Current group: {name}",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Current group: not assigned",
|
||||
"ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%",
|
||||
"ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "No discounts configured.",
|
||||
"ADMIN_USER_PROMO_GROUP_SELECT": "Select a promo group to assign:",
|
||||
"ADMIN_USER_PROMO_GROUP_UPDATED": "✅ User promo group updated: “{name}”",
|
||||
"ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ The user is already in this promo group.",
|
||||
"ADMIN_USER_PROMO_GROUP_ERROR": "❌ Failed to update the user's promo group.",
|
||||
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ Back to user",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 <b>Promo group:</b> {name}",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Members: {count}",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "This is the default group.",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Members",
|
||||
"ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Edit",
|
||||
"ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Delete",
|
||||
"ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Enter a name for the new promo group:",
|
||||
"ADMIN_PROMO_GROUP_INVALID_NAME": "Name cannot be empty.",
|
||||
"ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.",
|
||||
"ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.",
|
||||
"ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups",
|
||||
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.",
|
||||
"ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "The default promo group cannot be deleted.",
|
||||
"ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Delete promo group “{name}”? All users will be moved to the default group.",
|
||||
"ADMIN_PROMO_GROUP_DELETED": "Promo group “{name}” deleted.",
|
||||
"ADMIN_SUBSCRIPTIONS": "📱 Subscriptions",
|
||||
"ADMIN_USERS": "👥 Users",
|
||||
"AUTOPAY_DISABLED_TEXT": "Disabled — don't forget to renew manually!",
|
||||
@@ -139,6 +181,12 @@
|
||||
"CAMPAIGN_BONUS_BALANCE": "🎉 You received {amount} for registering via the \"{name}\" campaign!",
|
||||
"CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 You’ve been granted a {days}-day subscription (traffic: {traffic}, devices: {devices}) from the \"{name}\" campaign!",
|
||||
"BUY_SUBSCRIPTION_START": "\n💎 <b>Subscription setup</b>\n\nLet's configure a plan that fits you.\n\nFirst, choose the subscription period:\n",
|
||||
"PROMO_GROUP_DISCOUNTS_HEADER": "🎁 <b>Your promo group discounts</b>",
|
||||
"PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Servers: {percent}%",
|
||||
"PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Traffic: {percent}%",
|
||||
"PROMO_GROUP_DISCOUNT_DEVICES": "📱 Extra devices: {percent}%",
|
||||
"PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Long-term period discounts:",
|
||||
"PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%",
|
||||
"CHANGE_DEVICES_CONFIRM": "\n📱 <b>Confirm change</b>\n\nCurrent amount: {current_devices} devices\nNew amount: {new_devices} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n",
|
||||
"CHANGE_DEVICES_INFO": "\n📱 <b>Adjust device limit</b>\n\nCurrent limit: {current_devices} devices\n\nChoose the new number of devices:\n\n💡 <b>Important:</b>\n• Increasing — extra charge proportional to the remaining time\n• Decreasing — funds are not refunded\n",
|
||||
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n✅ Device limit decreased!\n\n📱 Was: {old_count} → Now: {new_count}\nℹ️ Payments are not refunded\n",
|
||||
|
||||
@@ -11,6 +11,48 @@
|
||||
"ADMIN_REMNAWAVE": "🖥️ Remnawave",
|
||||
"ADMIN_RULES": "📋 Правила",
|
||||
"ADMIN_STATISTICS": "📊 Статистика",
|
||||
"ADMIN_PROMO_GROUPS": "💳 Промогруппы",
|
||||
"ADMIN_PROMO_GROUPS_TITLE": "💳 <b>Промогруппы</b>",
|
||||
"ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}",
|
||||
"ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
|
||||
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)",
|
||||
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}",
|
||||
"ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.",
|
||||
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогруппа",
|
||||
"ADMIN_USER_PROMO_GROUP_TITLE": "👥 <b>Промогруппа пользователя</b>",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT": "Текущая группа: {name}",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Текущая группа: не назначена",
|
||||
"ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
|
||||
"ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "Скидки не заданы.",
|
||||
"ADMIN_USER_PROMO_GROUP_SELECT": "Выберите промогруппу для назначения:",
|
||||
"ADMIN_USER_PROMO_GROUP_UPDATED": "✅ Промогруппа пользователя обновлена: «{name}»",
|
||||
"ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.",
|
||||
"ADMIN_USER_PROMO_GROUP_ERROR": "❌ Не удалось обновить промогруппу пользователя.",
|
||||
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 <b>Промогруппа:</b> {name}",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Участников: {count}",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "Это базовая группа.",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Участники",
|
||||
"ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Изменить",
|
||||
"ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Удалить",
|
||||
"ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Введите название новой промогруппы:",
|
||||
"ADMIN_PROMO_GROUP_INVALID_NAME": "Название не может быть пустым.",
|
||||
"ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):",
|
||||
"ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.",
|
||||
"ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.",
|
||||
"ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам",
|
||||
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):",
|
||||
"ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.",
|
||||
"ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "Базовую промогруппу нельзя удалить.",
|
||||
"ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Удалить промогруппу «{name}»? Все пользователи будут переведены в базовую группу.",
|
||||
"ADMIN_PROMO_GROUP_DELETED": "Промогруппа «{name}» удалена.",
|
||||
"ADMIN_SUBSCRIPTIONS": "📱 Подписки",
|
||||
"ADMIN_USERS": "👥 Пользователи",
|
||||
"AUTOPAY_BUTTON": "💳 Автоплатёж",
|
||||
@@ -32,6 +74,12 @@
|
||||
"CAMPAIGN_BONUS_BALANCE": "🎉 Вы получили {amount} за регистрацию по кампании «{name}»!",
|
||||
"CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 Вам выдана подписка на {days} д. (трафик: {traffic}, устройств: {devices}) по кампании «{name}»!",
|
||||
"BUY_SUBSCRIPTION_START": "\n💎 <b>Настройка подписки</b>\n\nДавайте настроим вашу подписку под ваши потребности.\n\nСначала выберите период подписки:\n",
|
||||
"PROMO_GROUP_DISCOUNTS_HEADER": "🎁 <b>Скидки вашей промогруппы</b>",
|
||||
"PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Серверы: {percent}%",
|
||||
"PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Трафик: {percent}%",
|
||||
"PROMO_GROUP_DISCOUNT_DEVICES": "📱 Доп. устройства: {percent}%",
|
||||
"PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки за длительный период:",
|
||||
"PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%",
|
||||
"CANCEL": "❌ Отмена",
|
||||
"CHANGE_DEVICES_BUTTON": "📱 Изменить устройства",
|
||||
"CHANGE_DEVICES_CONFIRM": "\n 📱 <b>Подтверждение изменения</b>\n\n Текущее количество: {current_devices} устройств\n Новое количество: {new_devices} устройств\n\n Действие: {action}\n 💰 {cost}\n\n Подтвердить изменение?\n ",
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Optional, List, Tuple
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.models import Subscription, User, SubscriptionStatus
|
||||
from app.database.models import Subscription, User, SubscriptionStatus, PromoGroup
|
||||
from app.external.remnawave_api import (
|
||||
RemnaWaveAPI, RemnaWaveUser, UserStatus,
|
||||
TrafficLimitStrategy, RemnaWaveAPIError
|
||||
@@ -19,6 +19,25 @@ from app.utils.pricing_utils import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_discount_percent(
|
||||
user: Optional[User],
|
||||
promo_group: Optional[PromoGroup],
|
||||
category: str,
|
||||
*,
|
||||
period_days: Optional[int] = None,
|
||||
) -> int:
|
||||
if user is not None:
|
||||
try:
|
||||
return user.get_promo_discount(category, period_days)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
if promo_group is not None:
|
||||
return promo_group.get_discount_percent(category, period_days)
|
||||
|
||||
return 0
|
||||
|
||||
def get_traffic_reset_strategy():
|
||||
from app.config import settings
|
||||
strategy = settings.DEFAULT_TRAFFIC_RESET_STRATEGY.upper()
|
||||
@@ -266,9 +285,12 @@ class SubscriptionService:
|
||||
self,
|
||||
period_days: int,
|
||||
traffic_gb: int,
|
||||
server_squad_ids: List[int],
|
||||
server_squad_ids: List[int],
|
||||
devices: int,
|
||||
db: AsyncSession
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
promo_group: Optional[PromoGroup] = None,
|
||||
) -> Tuple[int, List[int]]:
|
||||
|
||||
from app.config import PERIOD_PRICES
|
||||
@@ -279,68 +301,167 @@ class SubscriptionService:
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
traffic_price = settings.get_traffic_price(traffic_gb)
|
||||
|
||||
traffic_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount = traffic_price * traffic_discount_percent // 100
|
||||
discounted_traffic_price = traffic_price - traffic_discount
|
||||
|
||||
server_prices = []
|
||||
total_servers_price = 0
|
||||
|
||||
servers_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
period_days=period_days,
|
||||
)
|
||||
|
||||
for server_id in server_squad_ids:
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if server and server.is_available and not server.is_full:
|
||||
server_prices.append(server.price_kopeks)
|
||||
total_servers_price += server.price_kopeks
|
||||
logger.debug(f"Сервер {server.display_name}: {server.price_kopeks/100}₽")
|
||||
server_price = server.price_kopeks
|
||||
server_discount = server_price * servers_discount_percent // 100
|
||||
discounted_server_price = server_price - server_discount
|
||||
server_prices.append(discounted_server_price)
|
||||
total_servers_price += discounted_server_price
|
||||
log_message = f"Сервер {server.display_name}: {server_price/100}₽"
|
||||
if server_discount > 0:
|
||||
log_message += (
|
||||
f" (скидка {servers_discount_percent}%: -{server_discount/100}₽ → {discounted_server_price/100}₽)"
|
||||
)
|
||||
logger.debug(log_message)
|
||||
else:
|
||||
server_prices.append(0)
|
||||
logger.warning(f"Сервер ID {server_id} недоступен")
|
||||
|
||||
|
||||
devices_price = max(0, devices - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
|
||||
total_price = base_price + traffic_price + total_servers_price + devices_price
|
||||
|
||||
devices_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount = devices_price * devices_discount_percent // 100
|
||||
discounted_devices_price = devices_price - devices_discount
|
||||
|
||||
total_price = base_price + discounted_traffic_price + total_servers_price + discounted_devices_price
|
||||
|
||||
logger.info(f"Расчет стоимости новой подписки:")
|
||||
logger.info(f" Период {period_days} дней: {base_price/100}₽")
|
||||
if traffic_price > 0:
|
||||
logger.info(f" Трафик {traffic_gb} ГБ: {traffic_price/100}₽")
|
||||
if discounted_traffic_price > 0:
|
||||
message = f" Трафик {traffic_gb} ГБ: {traffic_price/100}₽"
|
||||
if traffic_discount > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{traffic_discount/100}₽ → {discounted_traffic_price/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_servers_price > 0:
|
||||
logger.info(f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽")
|
||||
if devices_price > 0:
|
||||
logger.info(f" Устройства ({devices}): {devices_price/100}₽")
|
||||
message = f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽"
|
||||
if servers_discount_percent > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}% применяется ко всем серверам)"
|
||||
)
|
||||
logger.info(message)
|
||||
if discounted_devices_price > 0:
|
||||
message = f" Устройства ({devices}): {devices_price/100}₽"
|
||||
if devices_discount > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ → {discounted_devices_price/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" ИТОГО: {total_price/100}₽")
|
||||
|
||||
|
||||
return total_price, server_prices
|
||||
|
||||
async def calculate_renewal_price(
|
||||
self,
|
||||
subscription: Subscription,
|
||||
period_days: int,
|
||||
db: AsyncSession
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
promo_group: Optional[PromoGroup] = None,
|
||||
) -> int:
|
||||
try:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
|
||||
if user is None:
|
||||
user = getattr(subscription, "user", None)
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
servers_price, _ = await self.get_countries_price_by_uuids(
|
||||
subscription.connected_squads, db
|
||||
)
|
||||
|
||||
|
||||
servers_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
period_days=period_days,
|
||||
)
|
||||
servers_discount = servers_price * servers_discount_percent // 100
|
||||
discounted_servers_price = servers_price - servers_discount
|
||||
|
||||
devices_price = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
|
||||
devices_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount = devices_price * devices_discount_percent // 100
|
||||
discounted_devices_price = devices_price - devices_discount
|
||||
|
||||
traffic_price = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
|
||||
total_price = base_price + servers_price + devices_price + traffic_price
|
||||
|
||||
traffic_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount = traffic_price * traffic_discount_percent // 100
|
||||
discounted_traffic_price = traffic_price - traffic_discount
|
||||
|
||||
total_price = (
|
||||
base_price
|
||||
+ discounted_servers_price
|
||||
+ discounted_devices_price
|
||||
+ discounted_traffic_price
|
||||
)
|
||||
|
||||
logger.info(f"💰 Расчет стоимости продления для подписки {subscription.id} (по текущим ценам):")
|
||||
logger.info(f" 📅 Период {period_days} дней: {base_price/100}₽")
|
||||
if servers_price > 0:
|
||||
logger.info(f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {servers_price/100}₽")
|
||||
message = f" 🌍 Серверы ({len(subscription.connected_squads)}) по текущим ценам: {discounted_servers_price/100}₽"
|
||||
if servers_discount > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%: -{servers_discount/100}₽ от {servers_price/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if devices_price > 0:
|
||||
logger.info(f" 📱 Устройства ({subscription.device_limit}): {devices_price/100}₽")
|
||||
message = f" 📱 Устройства ({subscription.device_limit}): {discounted_devices_price/100}₽"
|
||||
if devices_discount > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{devices_discount/100}₽ от {devices_price/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if traffic_price > 0:
|
||||
logger.info(f" 📊 Трафик ({subscription.traffic_limit_gb} ГБ): {traffic_price/100}₽")
|
||||
message = f" 📊 Трафик ({subscription.traffic_limit_gb} ГБ): {discounted_traffic_price/100}₽"
|
||||
if traffic_discount > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{traffic_discount/100}₽ от {traffic_price/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" 💎 ИТОГО: {total_price/100}₽")
|
||||
|
||||
|
||||
return total_price
|
||||
|
||||
except Exception as e:
|
||||
@@ -440,9 +561,12 @@ class SubscriptionService:
|
||||
self,
|
||||
period_days: int,
|
||||
traffic_gb: int,
|
||||
server_squad_ids: List[int],
|
||||
server_squad_ids: List[int],
|
||||
devices: int,
|
||||
db: AsyncSession
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
promo_group: Optional[PromoGroup] = None,
|
||||
) -> Tuple[int, List[int]]:
|
||||
|
||||
from app.config import PERIOD_PRICES
|
||||
@@ -455,89 +579,183 @@ class SubscriptionService:
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(traffic_gb)
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
traffic_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
total_traffic_price = discounted_traffic_per_month * months_in_period
|
||||
|
||||
server_prices = []
|
||||
total_servers_price = 0
|
||||
|
||||
servers_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
period_days=period_days,
|
||||
)
|
||||
|
||||
for server_id in server_squad_ids:
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if server and server.is_available and not server.is_full:
|
||||
server_price_per_month = server.price_kopeks
|
||||
server_price_total = server_price_per_month * months_in_period
|
||||
server_discount_per_month = server_price_per_month * servers_discount_percent // 100
|
||||
discounted_server_per_month = server_price_per_month - server_discount_per_month
|
||||
server_price_total = discounted_server_per_month * months_in_period
|
||||
server_prices.append(server_price_total)
|
||||
total_servers_price += server_price_total
|
||||
logger.debug(f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_in_period} мес = {server_price_total/100}₽")
|
||||
log_message = (
|
||||
f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_in_period} мес = {server_price_total/100}₽"
|
||||
)
|
||||
if server_discount_per_month > 0:
|
||||
log_message += (
|
||||
f" (скидка {servers_discount_percent}%: -{server_discount_per_month * months_in_period/100}₽)"
|
||||
)
|
||||
logger.debug(log_message)
|
||||
else:
|
||||
server_prices.append(0)
|
||||
logger.warning(f"Сервер ID {server_id} недоступен")
|
||||
|
||||
|
||||
additional_devices = max(0, devices - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
devices_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
total_devices_price = discounted_devices_per_month * months_in_period
|
||||
|
||||
total_price = base_price + total_traffic_price + total_servers_price + total_devices_price
|
||||
|
||||
|
||||
logger.info(f"Расчет стоимости новой подписки на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" Период {period_days} дней: {base_price/100}₽")
|
||||
if total_traffic_price > 0:
|
||||
logger.info(
|
||||
message = (
|
||||
f" Трафик {traffic_gb} ГБ: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽"
|
||||
)
|
||||
if traffic_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_servers_price > 0:
|
||||
logger.info(f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽")
|
||||
message = f" Серверы ({len(server_squad_ids)}): {total_servers_price/100}₽"
|
||||
if servers_discount_percent > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}% применяется ко всем серверам)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_devices_price > 0:
|
||||
logger.info(
|
||||
message = (
|
||||
f" Устройства ({additional_devices}): {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}₽"
|
||||
)
|
||||
if devices_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" ИТОГО: {total_price/100}₽")
|
||||
|
||||
|
||||
return total_price, server_prices
|
||||
|
||||
async def calculate_renewal_price_with_months(
|
||||
self,
|
||||
subscription: Subscription,
|
||||
period_days: int,
|
||||
db: AsyncSession
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user: Optional[User] = None,
|
||||
promo_group: Optional[PromoGroup] = None,
|
||||
) -> int:
|
||||
try:
|
||||
from app.config import PERIOD_PRICES
|
||||
|
||||
|
||||
months_in_period = calculate_months_from_days(period_days)
|
||||
|
||||
|
||||
base_price = PERIOD_PRICES.get(period_days, 0)
|
||||
|
||||
|
||||
if user is None:
|
||||
user = getattr(subscription, "user", None)
|
||||
promo_group = promo_group or (user.promo_group if user else None)
|
||||
|
||||
servers_price_per_month, _ = await self.get_countries_price_by_uuids(
|
||||
subscription.connected_squads, db
|
||||
)
|
||||
total_servers_price = servers_price_per_month * months_in_period
|
||||
|
||||
servers_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
period_days=period_days,
|
||||
)
|
||||
servers_discount_per_month = servers_price_per_month * servers_discount_percent // 100
|
||||
discounted_servers_per_month = servers_price_per_month - servers_discount_per_month
|
||||
total_servers_price = discounted_servers_per_month * months_in_period
|
||||
|
||||
additional_devices = max(0, subscription.device_limit - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_devices_price = devices_price_per_month * months_in_period
|
||||
|
||||
devices_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
period_days=period_days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
total_devices_price = discounted_devices_per_month * months_in_period
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
total_traffic_price = traffic_price_per_month * months_in_period
|
||||
|
||||
traffic_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
period_days=period_days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
total_traffic_price = discounted_traffic_per_month * months_in_period
|
||||
|
||||
total_price = base_price + total_servers_price + total_devices_price + total_traffic_price
|
||||
|
||||
|
||||
logger.info(f"💰 Расчет стоимости продления подписки {subscription.id} на {period_days} дней ({months_in_period} мес):")
|
||||
logger.info(f" 📅 Период {period_days} дней: {base_price/100}₽")
|
||||
if total_servers_price > 0:
|
||||
logger.info(
|
||||
message = (
|
||||
f" 🌍 Серверы: {servers_price_per_month/100}₽/мес x {months_in_period} = {total_servers_price/100}₽"
|
||||
)
|
||||
if servers_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%: -{servers_discount_per_month * months_in_period/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_devices_price > 0:
|
||||
logger.info(
|
||||
message = (
|
||||
f" 📱 Устройства: {devices_price_per_month/100}₽/мес x {months_in_period} = {total_devices_price/100}₽"
|
||||
)
|
||||
if devices_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%: -{devices_discount_per_month * months_in_period/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
if total_traffic_price > 0:
|
||||
logger.info(
|
||||
message = (
|
||||
f" 📊 Трафик: {traffic_price_per_month/100}₽/мес x {months_in_period} = {total_traffic_price/100}₽"
|
||||
)
|
||||
if traffic_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%: -{traffic_discount_per_month * months_in_period/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
logger.info(f" 💎 ИТОГО: {total_price/100}₽")
|
||||
|
||||
|
||||
return total_price
|
||||
|
||||
except Exception as e:
|
||||
@@ -556,32 +774,92 @@ class SubscriptionService:
|
||||
|
||||
if additional_server_ids is None:
|
||||
additional_server_ids = []
|
||||
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
months_to_pay = get_remaining_months(subscription.end_date)
|
||||
|
||||
period_hint_days = months_to_pay * 30 if months_to_pay > 0 else None
|
||||
|
||||
user = getattr(subscription, "user", None)
|
||||
promo_group = user.promo_group if user else None
|
||||
|
||||
total_price = 0
|
||||
|
||||
|
||||
if additional_traffic_gb > 0:
|
||||
traffic_price_per_month = settings.get_traffic_price(additional_traffic_gb)
|
||||
total_price += traffic_price_per_month * months_to_pay
|
||||
logger.info(f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес x {months_to_pay} = {traffic_price_per_month * months_to_pay/100}₽")
|
||||
|
||||
traffic_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"traffic",
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
traffic_discount_per_month = traffic_price_per_month * traffic_discount_percent // 100
|
||||
discounted_traffic_per_month = traffic_price_per_month - traffic_discount_per_month
|
||||
traffic_total_price = discounted_traffic_per_month * months_to_pay
|
||||
total_price += traffic_total_price
|
||||
message = (
|
||||
f"Трафик +{additional_traffic_gb}ГБ: {traffic_price_per_month/100}₽/мес x {months_to_pay}"
|
||||
f" = {traffic_total_price/100}₽"
|
||||
)
|
||||
if traffic_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {traffic_discount_percent}%:"
|
||||
f" -{traffic_discount_per_month * months_to_pay/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
if additional_devices > 0:
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
total_price += devices_price_per_month * months_to_pay
|
||||
logger.info(f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес x {months_to_pay} = {devices_price_per_month * months_to_pay/100}₽")
|
||||
|
||||
devices_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"devices",
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
devices_discount_per_month = devices_price_per_month * devices_discount_percent // 100
|
||||
discounted_devices_per_month = devices_price_per_month - devices_discount_per_month
|
||||
devices_total_price = discounted_devices_per_month * months_to_pay
|
||||
total_price += devices_total_price
|
||||
message = (
|
||||
f"Устройства +{additional_devices}: {devices_price_per_month/100}₽/мес x {months_to_pay}"
|
||||
f" = {devices_total_price/100}₽"
|
||||
)
|
||||
if devices_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {devices_discount_percent}%:"
|
||||
f" -{devices_discount_per_month * months_to_pay/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
if additional_server_ids and db:
|
||||
for server_id in additional_server_ids:
|
||||
from app.database.crud.server_squad import get_server_squad_by_id
|
||||
server = await get_server_squad_by_id(db, server_id)
|
||||
if server and server.is_available:
|
||||
server_price_per_month = server.price_kopeks
|
||||
server_total_price = server_price_per_month * months_to_pay
|
||||
servers_discount_percent = _resolve_discount_percent(
|
||||
user,
|
||||
promo_group,
|
||||
"servers",
|
||||
period_days=period_hint_days,
|
||||
)
|
||||
server_discount_per_month = (
|
||||
server_price_per_month * servers_discount_percent // 100
|
||||
)
|
||||
discounted_server_per_month = (
|
||||
server_price_per_month - server_discount_per_month
|
||||
)
|
||||
server_total_price = discounted_server_per_month * months_to_pay
|
||||
total_price += server_total_price
|
||||
logger.info(f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_to_pay} = {server_total_price/100}₽")
|
||||
|
||||
message = (
|
||||
f"Сервер {server.display_name}: {server_price_per_month/100}₽/мес x {months_to_pay}"
|
||||
f" = {server_total_price/100}₽"
|
||||
)
|
||||
if server_discount_per_month > 0:
|
||||
message += (
|
||||
f" (скидка {servers_discount_percent}%:"
|
||||
f" -{server_discount_per_month * months_to_pay/100}₽)"
|
||||
)
|
||||
logger.info(message)
|
||||
|
||||
logger.info(f"Итого доплата за {months_to_pay} мес: {total_price/100}₽")
|
||||
return total_price
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import delete, select, update
|
||||
from aiogram import Bot
|
||||
@@ -10,13 +10,14 @@ from app.database.crud.user import (
|
||||
get_users_count, get_users_statistics, get_inactive_users,
|
||||
add_user_balance, subtract_user_balance, update_user, delete_user
|
||||
)
|
||||
from app.database.crud.promo_group import get_promo_group_by_id
|
||||
from app.database.crud.transaction import get_user_transactions_count
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.models import (
|
||||
User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse,
|
||||
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
|
||||
CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText,
|
||||
SentNotification
|
||||
User, UserStatus, Subscription, Transaction, PromoCode, PromoCodeUse,
|
||||
ReferralEarning, SubscriptionServer, YooKassaPayment, BroadcastHistory,
|
||||
CryptoBotPayment, SubscriptionConversion, UserMessage, WelcomeText,
|
||||
SentNotification, PromoGroup
|
||||
)
|
||||
from app.config import settings
|
||||
|
||||
@@ -192,10 +193,10 @@ class UserService:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
|
||||
# Сохраняем старый баланс для уведомления
|
||||
old_balance = user.balance_kopeks
|
||||
|
||||
|
||||
if amount_kopeks > 0:
|
||||
await add_user_balance(db, user, amount_kopeks, description=description)
|
||||
logger.info(f"Админ {admin_id} пополнил баланс пользователя {user_id} на {amount_kopeks/100}₽")
|
||||
@@ -204,26 +205,61 @@ class UserService:
|
||||
success = await subtract_user_balance(db, user, abs(amount_kopeks), description)
|
||||
if success:
|
||||
logger.info(f"Админ {admin_id} списал с баланса пользователя {user_id} {abs(amount_kopeks)/100}₽")
|
||||
|
||||
|
||||
# Отправляем уведомление пользователю, если операция прошла успешно
|
||||
if success and bot:
|
||||
# Обновляем пользователя для получения нового баланса
|
||||
await db.refresh(user)
|
||||
|
||||
|
||||
# Получаем имя администратора
|
||||
if not admin_name:
|
||||
admin_user = await get_user_by_id(db, admin_id)
|
||||
admin_name = admin_user.full_name if admin_user else f"Админ #{admin_id}"
|
||||
|
||||
|
||||
# Отправляем уведомление (не блокируем операцию если не удалось отправить)
|
||||
await self._send_balance_notification(bot, user, amount_kopeks, admin_name)
|
||||
|
||||
|
||||
return success
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка изменения баланса пользователя: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def update_user_promo_group(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
promo_group_id: int
|
||||
) -> Tuple[bool, Optional[User], Optional[PromoGroup]]:
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return False, None, None
|
||||
|
||||
promo_group = await get_promo_group_by_id(db, promo_group_id)
|
||||
if not promo_group:
|
||||
return False, None, None
|
||||
|
||||
user.promo_group_id = promo_group.id
|
||||
user.promo_group = promo_group
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
logger.info(
|
||||
"👥 Промогруппа пользователя %s обновлена на '%s'",
|
||||
user.telegram_id,
|
||||
promo_group.name,
|
||||
)
|
||||
|
||||
return True, user, promo_group
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"Ошибка обновления промогруппы пользователя {user_id}: {e}")
|
||||
return False, None, None
|
||||
|
||||
async def block_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
|
||||
+11
-1
@@ -59,10 +59,20 @@ class AdminStates(StatesGroup):
|
||||
editing_campaign_subscription_traffic = State()
|
||||
editing_campaign_subscription_devices = State()
|
||||
editing_campaign_subscription_servers = State()
|
||||
|
||||
|
||||
waiting_for_broadcast_message = State()
|
||||
waiting_for_broadcast_media = State()
|
||||
confirming_broadcast = State()
|
||||
|
||||
creating_promo_group_name = State()
|
||||
creating_promo_group_traffic_discount = State()
|
||||
creating_promo_group_server_discount = State()
|
||||
creating_promo_group_device_discount = State()
|
||||
|
||||
editing_promo_group_name = State()
|
||||
editing_promo_group_traffic_discount = State()
|
||||
editing_promo_group_server_discount = State()
|
||||
editing_promo_group_device_discount = State()
|
||||
|
||||
editing_squad_price = State()
|
||||
editing_traffic_price = State()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from typing import Tuple
|
||||
import logging
|
||||
|
||||
@@ -28,8 +29,8 @@ def calculate_period_multiplier(period_days: int) -> Tuple[int, float]:
|
||||
|
||||
|
||||
def calculate_prorated_price(
|
||||
monthly_price: int,
|
||||
end_date: datetime,
|
||||
monthly_price: int,
|
||||
end_date: datetime,
|
||||
min_charge_months: int = 1
|
||||
) -> Tuple[int, int]:
|
||||
months_remaining = get_remaining_months(end_date)
|
||||
@@ -42,6 +43,25 @@ def calculate_prorated_price(
|
||||
return total_price, months_to_charge
|
||||
|
||||
|
||||
def apply_percentage_discount(amount: int, percent: int) -> Tuple[int, int]:
|
||||
if amount <= 0 or percent <= 0:
|
||||
return amount, 0
|
||||
|
||||
clamped_percent = max(0, min(100, percent))
|
||||
discount_value = amount * clamped_percent // 100
|
||||
discounted_amount = amount - discount_value
|
||||
|
||||
logger.debug(
|
||||
"Применена скидка %s%%: %s → %s (скидка %s)",
|
||||
clamped_percent,
|
||||
amount,
|
||||
discounted_amount,
|
||||
discount_value,
|
||||
)
|
||||
|
||||
return discounted_amount, discount_value
|
||||
|
||||
|
||||
def format_period_description(days: int, language: str = "ru") -> str:
|
||||
months = calculate_months_from_days(days)
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""add promo groups table and link users"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
PROMO_GROUPS_TABLE = "promo_groups"
|
||||
USERS_TABLE = "users"
|
||||
PROMO_GROUP_COLUMN = "promo_group_id"
|
||||
PROMO_GROUP_INDEX = "ix_users_promo_group_id"
|
||||
PROMO_GROUP_FK = "fk_users_promo_group_id_promo_groups"
|
||||
DEFAULT_PROMO_GROUP_NAME = "Базовый юзер"
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _column_exists(inspector: sa.Inspector, table_name: str, column_name: str) -> bool:
|
||||
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def _index_exists(inspector: sa.Inspector, table_name: str, index_name: str) -> bool:
|
||||
return any(index["name"] == index_name for index in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
def _foreign_key_exists(inspector: sa.Inspector, table_name: str, fk_name: str) -> bool:
|
||||
return any(fk["name"] == fk_name for fk in inspector.get_foreign_keys(table_name))
|
||||
|
||||
revision: str = "1f5f3a3f5a4d"
|
||||
down_revision: Union[str, None] = "cbd1be472f3d"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not _table_exists(inspector, PROMO_GROUPS_TABLE):
|
||||
op.create_table(
|
||||
PROMO_GROUPS_TABLE,
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column(
|
||||
"server_discount_percent",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
),
|
||||
sa.Column(
|
||||
"traffic_discount_percent",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
),
|
||||
sa.Column(
|
||||
"device_discount_percent",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default=sa.text("0"),
|
||||
),
|
||||
sa.Column(
|
||||
"is_default",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.UniqueConstraint("name", name="uq_promo_groups_name"),
|
||||
)
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not _column_exists(inspector, USERS_TABLE, PROMO_GROUP_COLUMN):
|
||||
op.add_column(
|
||||
USERS_TABLE,
|
||||
sa.Column(PROMO_GROUP_COLUMN, sa.Integer(), nullable=True),
|
||||
)
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _column_exists(inspector, USERS_TABLE, PROMO_GROUP_COLUMN):
|
||||
if not _index_exists(inspector, USERS_TABLE, PROMO_GROUP_INDEX):
|
||||
op.create_index(PROMO_GROUP_INDEX, USERS_TABLE, [PROMO_GROUP_COLUMN])
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if not _foreign_key_exists(inspector, USERS_TABLE, PROMO_GROUP_FK):
|
||||
op.create_foreign_key(
|
||||
PROMO_GROUP_FK,
|
||||
USERS_TABLE,
|
||||
PROMO_GROUPS_TABLE,
|
||||
[PROMO_GROUP_COLUMN],
|
||||
["id"],
|
||||
ondelete="RESTRICT",
|
||||
)
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if not _table_exists(inspector, PROMO_GROUPS_TABLE) or not _column_exists(
|
||||
inspector, USERS_TABLE, PROMO_GROUP_COLUMN
|
||||
):
|
||||
return
|
||||
|
||||
promo_groups_table = sa.table(
|
||||
PROMO_GROUPS_TABLE,
|
||||
sa.column("id", sa.Integer()),
|
||||
sa.column("name", sa.String()),
|
||||
sa.column("server_discount_percent", sa.Integer()),
|
||||
sa.column("traffic_discount_percent", sa.Integer()),
|
||||
sa.column("device_discount_percent", sa.Integer()),
|
||||
sa.column("is_default", sa.Boolean()),
|
||||
)
|
||||
|
||||
connection = bind
|
||||
existing_named_group = (
|
||||
connection.execute(
|
||||
sa.select(
|
||||
promo_groups_table.c.id,
|
||||
promo_groups_table.c.is_default,
|
||||
)
|
||||
.where(promo_groups_table.c.name == DEFAULT_PROMO_GROUP_NAME)
|
||||
.limit(1)
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing_named_group:
|
||||
default_group_id = existing_named_group["id"]
|
||||
if not existing_named_group["is_default"]:
|
||||
connection.execute(
|
||||
sa.update(promo_groups_table)
|
||||
.where(promo_groups_table.c.id == default_group_id)
|
||||
.values(is_default=True)
|
||||
)
|
||||
else:
|
||||
default_group_id = connection.execute(
|
||||
sa.select(promo_groups_table.c.id)
|
||||
.where(promo_groups_table.c.is_default.is_(True))
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if default_group_id is None:
|
||||
default_group_id = connection.execute(
|
||||
sa.insert(promo_groups_table)
|
||||
.values(
|
||||
name=DEFAULT_PROMO_GROUP_NAME,
|
||||
server_discount_percent=0,
|
||||
traffic_discount_percent=0,
|
||||
device_discount_percent=0,
|
||||
is_default=True,
|
||||
)
|
||||
.returning(promo_groups_table.c.id)
|
||||
).scalar_one()
|
||||
|
||||
users_table = sa.table(
|
||||
USERS_TABLE,
|
||||
sa.column("promo_group_id", sa.Integer()),
|
||||
)
|
||||
connection.execute(
|
||||
sa.update(users_table)
|
||||
.where(users_table.c.promo_group_id.is_(None))
|
||||
.values(promo_group_id=default_group_id)
|
||||
)
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
column_info = next(
|
||||
(col for col in inspector.get_columns(USERS_TABLE) if col["name"] == PROMO_GROUP_COLUMN),
|
||||
None,
|
||||
)
|
||||
if column_info and column_info.get("nullable", True):
|
||||
op.alter_column(
|
||||
USERS_TABLE,
|
||||
PROMO_GROUP_COLUMN,
|
||||
existing_type=sa.Integer(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _column_exists(inspector, USERS_TABLE, PROMO_GROUP_COLUMN):
|
||||
column_info = next(
|
||||
(
|
||||
col
|
||||
for col in inspector.get_columns(USERS_TABLE)
|
||||
if col["name"] == PROMO_GROUP_COLUMN
|
||||
),
|
||||
None,
|
||||
)
|
||||
if column_info and not column_info.get("nullable", False):
|
||||
op.alter_column(
|
||||
USERS_TABLE,
|
||||
PROMO_GROUP_COLUMN,
|
||||
existing_type=sa.Integer(),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if _foreign_key_exists(inspector, USERS_TABLE, PROMO_GROUP_FK):
|
||||
op.drop_constraint(PROMO_GROUP_FK, USERS_TABLE, type_="foreignkey")
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if _index_exists(inspector, USERS_TABLE, PROMO_GROUP_INDEX):
|
||||
op.drop_index(PROMO_GROUP_INDEX, table_name=USERS_TABLE)
|
||||
|
||||
op.drop_column(USERS_TABLE, PROMO_GROUP_COLUMN)
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if _table_exists(inspector, PROMO_GROUPS_TABLE):
|
||||
op.drop_table(PROMO_GROUPS_TABLE)
|
||||
@@ -1,29 +0,0 @@
|
||||
"""add_paid_price_to_subscription
|
||||
|
||||
Revision ID: 3d9b35c6bd8f
|
||||
Revises:
|
||||
Create Date: 2025-08-23 08:17:00.563340
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '3d9b35c6bd8f'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('subscriptions', sa.Column('paid_price_kopeks', sa.Integer(), nullable=False, server_default='0'))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('subscriptions', 'paid_price_kopeks')
|
||||
# ### end Alembic commands ###
|
||||
@@ -6,6 +6,21 @@ from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
CAMPAIGNS_TABLE = "advertising_campaigns"
|
||||
CAMPAIGNS_START_INDEX = "ix_advertising_campaigns_start_parameter"
|
||||
CAMPAIGNS_ID_INDEX = "ix_advertising_campaigns_id"
|
||||
REGISTRATIONS_TABLE = "advertising_campaign_registrations"
|
||||
REGISTRATIONS_ID_INDEX = "ix_advertising_campaign_registrations_id"
|
||||
|
||||
|
||||
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _index_exists(inspector: sa.Inspector, table_name: str, index_name: str) -> bool:
|
||||
return any(index["name"] == index_name for index in inspector.get_indexes(table_name))
|
||||
|
||||
|
||||
revision: str = "5d1f1f8b2e9a"
|
||||
down_revision: Union[str, None] = "cbd1be472f3d"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
@@ -13,58 +28,119 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"advertising_campaigns",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("start_parameter", sa.String(length=64), nullable=False),
|
||||
sa.Column("bonus_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("balance_bonus_kopeks", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("subscription_duration_days", sa.Integer(), nullable=True),
|
||||
sa.Column("subscription_traffic_gb", sa.Integer(), nullable=True),
|
||||
sa.Column("subscription_device_limit", sa.Integer(), nullable=True),
|
||||
sa.Column("subscription_squads", sa.JSON(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column("created_by", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_advertising_campaigns_start_parameter",
|
||||
"advertising_campaigns",
|
||||
["start_parameter"],
|
||||
unique=True,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_advertising_campaigns_id",
|
||||
"advertising_campaigns",
|
||||
["id"],
|
||||
)
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
op.create_table(
|
||||
"advertising_campaign_registrations",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("campaign_id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("bonus_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("balance_bonus_kopeks", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("subscription_duration_days", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["campaign_id"], ["advertising_campaigns.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.UniqueConstraint("campaign_id", "user_id", name="uq_campaign_user"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_advertising_campaign_registrations_id",
|
||||
"advertising_campaign_registrations",
|
||||
["id"],
|
||||
)
|
||||
if not _table_exists(inspector, CAMPAIGNS_TABLE):
|
||||
op.create_table(
|
||||
CAMPAIGNS_TABLE,
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("start_parameter", sa.String(length=64), nullable=False),
|
||||
sa.Column("bonus_type", sa.String(length=20), nullable=False),
|
||||
sa.Column(
|
||||
"balance_bonus_kopeks",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
sa.Column("subscription_duration_days", sa.Integer(), nullable=True),
|
||||
sa.Column("subscription_traffic_gb", sa.Integer(), nullable=True),
|
||||
sa.Column("subscription_device_limit", sa.Integer(), nullable=True),
|
||||
sa.Column("subscription_squads", sa.JSON(), nullable=True),
|
||||
sa.Column(
|
||||
"is_active",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("true"),
|
||||
),
|
||||
sa.Column("created_by", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"),
|
||||
)
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not _index_exists(inspector, CAMPAIGNS_TABLE, CAMPAIGNS_START_INDEX):
|
||||
op.create_index(
|
||||
CAMPAIGNS_START_INDEX,
|
||||
CAMPAIGNS_TABLE,
|
||||
["start_parameter"],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if not _index_exists(inspector, CAMPAIGNS_TABLE, CAMPAIGNS_ID_INDEX):
|
||||
op.create_index(CAMPAIGNS_ID_INDEX, CAMPAIGNS_TABLE, ["id"])
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if not _table_exists(inspector, REGISTRATIONS_TABLE):
|
||||
op.create_table(
|
||||
REGISTRATIONS_TABLE,
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("campaign_id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("bonus_type", sa.String(length=20), nullable=False),
|
||||
sa.Column(
|
||||
"balance_bonus_kopeks",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
sa.Column("subscription_duration_days", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["campaign_id"],
|
||||
[f"{CAMPAIGNS_TABLE}.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||
sa.UniqueConstraint("campaign_id", "user_id", name="uq_campaign_user"),
|
||||
)
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not _index_exists(inspector, REGISTRATIONS_TABLE, REGISTRATIONS_ID_INDEX):
|
||||
op.create_index(
|
||||
REGISTRATIONS_ID_INDEX,
|
||||
REGISTRATIONS_TABLE,
|
||||
["id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_advertising_campaign_registrations_id", table_name="advertising_campaign_registrations")
|
||||
op.drop_table("advertising_campaign_registrations")
|
||||
op.drop_index("ix_advertising_campaigns_id", table_name="advertising_campaigns")
|
||||
op.drop_index("ix_advertising_campaigns_start_parameter", table_name="advertising_campaigns")
|
||||
op.drop_table("advertising_campaigns")
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _index_exists(inspector, REGISTRATIONS_TABLE, REGISTRATIONS_ID_INDEX):
|
||||
op.drop_index(REGISTRATIONS_ID_INDEX, table_name=REGISTRATIONS_TABLE)
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if _table_exists(inspector, REGISTRATIONS_TABLE):
|
||||
op.drop_table(REGISTRATIONS_TABLE)
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if _index_exists(inspector, CAMPAIGNS_TABLE, CAMPAIGNS_ID_INDEX):
|
||||
op.drop_index(CAMPAIGNS_ID_INDEX, table_name=CAMPAIGNS_TABLE)
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if _index_exists(inspector, CAMPAIGNS_TABLE, CAMPAIGNS_START_INDEX):
|
||||
op.drop_index(CAMPAIGNS_START_INDEX, table_name=CAMPAIGNS_TABLE)
|
||||
|
||||
inspector = sa.inspect(bind)
|
||||
if _table_exists(inspector, CAMPAIGNS_TABLE):
|
||||
op.drop_table(CAMPAIGNS_TABLE)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""add sent notifications table"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.engine.reflection import Inspector
|
||||
|
||||
|
||||
revision: str = '8fd1e338eb45'
|
||||
down_revision: Union[str, None] = '3d9b35c6bd8f'
|
||||
@@ -10,18 +13,46 @@ branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
TABLE_NAME = 'sent_notifications'
|
||||
UNIQUE_CONSTRAINT_NAME = 'uq_sent_notifications'
|
||||
UNIQUE_CONSTRAINT_COLUMNS = ['user_id', 'subscription_id', 'notification_type', 'days_before']
|
||||
|
||||
|
||||
def _table_exists(inspector: Inspector) -> bool:
|
||||
return TABLE_NAME in inspector.get_table_names()
|
||||
|
||||
|
||||
def _unique_constraint_exists(inspector: Inspector) -> bool:
|
||||
existing_constraints = {
|
||||
constraint['name'] for constraint in inspector.get_unique_constraints(TABLE_NAME)
|
||||
}
|
||||
return UNIQUE_CONSTRAINT_NAME in existing_constraints
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'sent_notifications',
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
|
||||
sa.Column('subscription_id', sa.Integer(), sa.ForeignKey('subscriptions.id'), nullable=False),
|
||||
sa.Column('notification_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('days_before', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
|
||||
sa.UniqueConstraint('user_id', 'subscription_id', 'notification_type', 'days_before', name='uq_sent_notifications'),
|
||||
)
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if not _table_exists(inspector):
|
||||
op.create_table(
|
||||
TABLE_NAME,
|
||||
sa.Column('id', sa.Integer(), primary_key=True),
|
||||
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
|
||||
sa.Column('subscription_id', sa.Integer(), sa.ForeignKey('subscriptions.id'), nullable=False),
|
||||
sa.Column('notification_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('days_before', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
|
||||
sa.UniqueConstraint(*UNIQUE_CONSTRAINT_COLUMNS, name=UNIQUE_CONSTRAINT_NAME),
|
||||
)
|
||||
elif not _unique_constraint_exists(inspector):
|
||||
op.create_unique_constraint(
|
||||
UNIQUE_CONSTRAINT_NAME, TABLE_NAME, UNIQUE_CONSTRAINT_COLUMNS
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('sent_notifications')
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
|
||||
if _table_exists(inspector):
|
||||
op.drop_table(TABLE_NAME)
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
"""add cascade delete to sent notifications"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = 'cbd1be472f3d'
|
||||
down_revision: Union[str, None] = '8fd1e338eb45'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_constraint('sent_notifications_user_id_fkey', 'sent_notifications', type_='foreignkey')
|
||||
op.drop_constraint('sent_notifications_subscription_id_fkey', 'sent_notifications', type_='foreignkey')
|
||||
op.create_foreign_key('fk_sent_notifications_user_id_users', 'sent_notifications', 'users', ['user_id'], ['id'], ondelete='CASCADE')
|
||||
op.create_foreign_key('fk_sent_notifications_subscription_id_subscriptions', 'sent_notifications', 'subscriptions', ['subscription_id'], ['id'], ondelete='CASCADE')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint('fk_sent_notifications_user_id_users', 'sent_notifications', type_='foreignkey')
|
||||
op.drop_constraint('fk_sent_notifications_subscription_id_subscriptions', 'sent_notifications', type_='foreignkey')
|
||||
op.create_foreign_key('sent_notifications_user_id_fkey', 'sent_notifications', 'users', ['user_id'], ['id'])
|
||||
op.create_foreign_key('sent_notifications_subscription_id_fkey', 'sent_notifications', 'subscriptions', ['subscription_id'], ['id'])
|
||||
Reference in New Issue
Block a user