Merge pull request #49 from yazhog/main
Режим link для ссылки подписки, фикс напоминаний о истечении подписки
This commit is contained in:
@@ -194,6 +194,7 @@ CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
|
||||
# guide - открывает гайд подключения (режим 1)
|
||||
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
|
||||
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
|
||||
# link - открывает ссылку подписки напрямую (режим 4)
|
||||
CONNECT_BUTTON_MODE=guide
|
||||
|
||||
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
|
||||
|
||||
@@ -362,6 +362,7 @@ PAYMENT_SUBSCRIPTION_TEMPLATE={service_name} - {description}
|
||||
# guide - открывает гайд подключения (режим 1)
|
||||
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
|
||||
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
|
||||
# link - открывает ссылку подписки напрямую (режим 4)
|
||||
CONNECT_BUTTON_MODE=guide
|
||||
|
||||
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, delete
|
||||
|
||||
from app.database.models import SentNotification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def notification_sent(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
subscription_id: int,
|
||||
notification_type: str,
|
||||
days_before: Optional[int] = None,
|
||||
) -> bool:
|
||||
result = await db.execute(
|
||||
select(SentNotification).where(
|
||||
SentNotification.user_id == user_id,
|
||||
SentNotification.subscription_id == subscription_id,
|
||||
SentNotification.notification_type == notification_type,
|
||||
SentNotification.days_before == days_before,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def record_notification(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
subscription_id: int,
|
||||
notification_type: str,
|
||||
days_before: Optional[int] = None,
|
||||
) -> None:
|
||||
notification = SentNotification(
|
||||
user_id=user_id,
|
||||
subscription_id=subscription_id,
|
||||
notification_type=notification_type,
|
||||
days_before=days_before,
|
||||
)
|
||||
db.add(notification)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def clear_notifications(db: AsyncSession, subscription_id: int) -> None:
|
||||
await db.execute(
|
||||
delete(SentNotification).where(
|
||||
SentNotification.subscription_id == subscription_id
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
@@ -6,9 +6,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
Subscription, SubscriptionStatus, User,
|
||||
Subscription, SubscriptionStatus, User,
|
||||
SubscriptionServer
|
||||
)
|
||||
from app.database.crud.notification import clear_notifications
|
||||
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
|
||||
from app.config import settings
|
||||
|
||||
@@ -121,10 +122,11 @@ async def extend_subscription(
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
await clear_notifications(db, subscription.id)
|
||||
|
||||
logger.info(f"✅ Подписка продлена до: {subscription.end_date}")
|
||||
logger.info(f"📊 Новые параметры: статус={subscription.status}, окончание={subscription.end_date}")
|
||||
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
|
||||
@@ -555,6 +555,20 @@ class MonitoringLog(Base):
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
|
||||
class SentNotification(Base):
|
||||
__tablename__ = "sent_notifications"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="CASCADE"), nullable=False)
|
||||
notification_type = Column(String(50), nullable=False)
|
||||
days_before = Column(Integer, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
user = relationship("User", backref="sent_notifications")
|
||||
subscription = relationship("Subscription", backref="sent_notifications")
|
||||
|
||||
class BroadcastHistory(Base):
|
||||
__tablename__ = "broadcast_history"
|
||||
|
||||
|
||||
@@ -416,6 +416,12 @@ async def activate_trial(
|
||||
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
|
||||
[InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")],
|
||||
])
|
||||
elif connect_mode == "link":
|
||||
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔗 Подключиться", url=subscription.subscription_url)],
|
||||
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
|
||||
[InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")],
|
||||
])
|
||||
else:
|
||||
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")],
|
||||
@@ -1920,6 +1926,12 @@ async def confirm_purchase(
|
||||
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
|
||||
[InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")],
|
||||
])
|
||||
elif connect_mode == "link":
|
||||
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔗 Подключиться", url=subscription.subscription_url)],
|
||||
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
|
||||
[InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")],
|
||||
])
|
||||
else:
|
||||
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")],
|
||||
@@ -2687,7 +2699,30 @@ async def handle_connect_subscription(
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
elif connect_mode == "link":
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text="🔗 Подключиться",
|
||||
url=subscription.subscription_url
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
|
||||
]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"""
|
||||
🚀 <b>Подключить подписку</b>
|
||||
|
||||
🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:
|
||||
""",
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
else:
|
||||
device_text = f"""
|
||||
📱 <b>Подключить подписку</b>
|
||||
|
||||
@@ -163,6 +163,10 @@ def get_subscription_keyboard(
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")
|
||||
])
|
||||
elif connect_mode == "link":
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="🔗 Подключиться", url=subscription.subscription_url)
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")
|
||||
|
||||
@@ -17,6 +17,10 @@ from app.database.crud.user import (
|
||||
get_user_by_id, get_inactive_users, delete_user,
|
||||
subtract_user_balance
|
||||
)
|
||||
from app.database.crud.notification import (
|
||||
notification_sent,
|
||||
record_notification,
|
||||
)
|
||||
from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.services.payment_service import PaymentService
|
||||
@@ -186,31 +190,30 @@ class MonitoringService:
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if not user:
|
||||
continue
|
||||
|
||||
notification_key = f"expiring_{user.telegram_id}_{days}d_{subscription.id}"
|
||||
|
||||
user_key = f"user_{user.telegram_id}_today"
|
||||
|
||||
if (notification_key in self._notified_users or
|
||||
|
||||
if (await notification_sent(db, user.id, subscription.id, "expiring", days) or
|
||||
user_key in all_processed_users):
|
||||
logger.debug(f"🔄 Пропускаем дублирование для пользователя {user.telegram_id} на {days} дней")
|
||||
continue
|
||||
|
||||
|
||||
should_send = True
|
||||
for other_days in warning_days:
|
||||
if other_days < days:
|
||||
if other_days < days:
|
||||
other_subs = await self._get_expiring_paid_subscriptions(db, other_days)
|
||||
if any(s.user_id == user.id for s in other_subs):
|
||||
should_send = False
|
||||
logger.debug(f"🎯 Пропускаем уведомление на {days} дней для пользователя {user.telegram_id}, есть более срочное на {other_days} дней")
|
||||
break
|
||||
|
||||
|
||||
if not should_send:
|
||||
continue
|
||||
|
||||
|
||||
if self.bot:
|
||||
success = await self._send_subscription_expiring_notification(user, subscription, days)
|
||||
if success:
|
||||
self._notified_users.add(notification_key)
|
||||
await record_notification(db, user.id, subscription.id, "expiring", days)
|
||||
all_processed_users.add(user_key)
|
||||
sent_count += 1
|
||||
logger.info(f"✅ Пользователю {user.telegram_id} отправлено уведомление об истечении подписки через {days} дней")
|
||||
@@ -249,15 +252,14 @@ class MonitoringService:
|
||||
user = subscription.user
|
||||
if not user:
|
||||
continue
|
||||
|
||||
notification_key = f"trial_2h_{user.telegram_id}_{subscription.id}"
|
||||
if notification_key in self._notified_users:
|
||||
continue
|
||||
|
||||
|
||||
if await notification_sent(db, user.id, subscription.id, "trial_2h"):
|
||||
continue
|
||||
|
||||
if self.bot:
|
||||
success = await self._send_trial_ending_notification(user, subscription)
|
||||
if success:
|
||||
self._notified_users.add(notification_key)
|
||||
await record_notification(db, user.id, subscription.id, "trial_2h")
|
||||
logger.info(f"🎁 Пользователю {user.telegram_id} отправлено уведомление об окончании тестовой подписки через 2 часа")
|
||||
|
||||
if trial_expiring:
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""add sent notifications table"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '8fd1e338eb45'
|
||||
down_revision: Union[str, None] = '3d9b35c6bd8f'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
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'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('sent_notifications')
|
||||
@@ -0,0 +1,24 @@
|
||||
"""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