From 25318c1c416a95599aa2ef20471cfbd798ad173c Mon Sep 17 00:00:00 2001 From: Egor Date: Thu, 22 Jan 2026 23:08:19 +0300 Subject: [PATCH] Update admin_promo_offers.py --- app/cabinet/routes/admin_promo_offers.py | 164 ++++++++++++++++++++++- 1 file changed, 162 insertions(+), 2 deletions(-) diff --git a/app/cabinet/routes/admin_promo_offers.py b/app/cabinet/routes/admin_promo_offers.py index 6265e92d..1c6d5cdc 100644 --- a/app/cabinet/routes/admin_promo_offers.py +++ b/app/cabinet/routes/admin_promo_offers.py @@ -2,13 +2,21 @@ from __future__ import annotations +import asyncio +import logging from datetime import datetime from typing import Any, Dict, List, Optional +from aiogram import Bot +from aiogram.client.default import DefaultBotProperties +from aiogram.enums import ParseMode +from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession +from app.config import settings from app.database.crud.discount_offer import ( count_discount_offers, list_discount_offers, @@ -24,9 +32,12 @@ from app.database.crud.promo_offer_template import ( from app.database.crud.user import get_user_by_telegram_id from app.database.models import DiscountOffer, PromoOfferLog, PromoOfferTemplate, User from app.handlers.admin.messages import get_custom_users, get_target_users +from app.utils.miniapp_buttons import build_miniapp_or_callback_button from ..dependencies import get_cabinet_db, get_current_admin_user +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/admin/promo-offers", tags=["Admin Promo Offers"]) @@ -110,12 +121,18 @@ class PromoOfferBroadcastRequest(BaseModel): target: Optional[str] = None user_id: Optional[int] = None telegram_id: Optional[int] = None + # Telegram notification options + send_notification: bool = Field(False, description="Send Telegram notification to users") + message_text: Optional[str] = Field(None, description="Custom message text (HTML)") + button_text: Optional[str] = Field(None, description="Button text") class PromoOfferBroadcastResponse(BaseModel): created_offers: int user_ids: List[int] target: Optional[str] = None + notifications_sent: int = 0 + notifications_failed: int = 0 class PromoOfferLogOfferInfo(BaseModel): @@ -344,13 +361,137 @@ async def list_offers( ) +def _get_bot() -> Bot: + """Create bot instance for sending notifications.""" + return Bot( + token=settings.BOT_TOKEN, + default=DefaultBotProperties(parse_mode=ParseMode.HTML), + ) + + +def _build_default_promo_message( + discount_percent: int, + bonus_amount_kopeks: int, + valid_hours: int, +) -> str: + """Build default promo notification message.""" + lines = ["🎁 Π‘ΠΏΠ΅Ρ†ΠΈΠ°Π»ΡŒΠ½ΠΎΠ΅ ΠΏΡ€Π΅Π΄Π»ΠΎΠΆΠ΅Π½ΠΈΠ΅ для вас!\n"] + + if discount_percent > 0: + lines.append(f"πŸ”₯ Π‘ΠΊΠΈΠ΄ΠΊΠ° {discount_percent}% Π½Π° подписку") + if bonus_amount_kopeks > 0: + bonus_rub = bonus_amount_kopeks / 100 + lines.append(f"πŸ’° Бонус {bonus_rub:.0f}β‚½ Π½Π° баланс") + + lines.append(f"\n⏰ ΠŸΡ€Π΅Π΄Π»ΠΎΠΆΠ΅Π½ΠΈΠ΅ дСйствуСт {valid_hours} Ρ‡.") + lines.append("\nНаТмитС ΠΊΠ½ΠΎΠΏΠΊΡƒ Π½ΠΈΠΆΠ΅, Ρ‡Ρ‚ΠΎΠ±Ρ‹ Π°ΠΊΡ‚ΠΈΠ²ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ!") + + return "\n".join(lines) + + +async def _send_promo_notifications( + offers_to_notify: List[tuple[User, DiscountOffer]], + message_text: Optional[str], + button_text: Optional[str], + discount_percent: int, + bonus_amount_kopeks: int, + valid_hours: int, +) -> tuple[int, int]: + """Send Telegram notifications for promo offers. + + Returns: + Tuple of (sent_count, failed_count) + """ + if not offers_to_notify: + return 0, 0 + + bot = _get_bot() + sent = 0 + failed = 0 + + # Build message text + text = message_text or _build_default_promo_message( + discount_percent=discount_percent, + bonus_amount_kopeks=bonus_amount_kopeks, + valid_hours=valid_hours, + ) + + # Default button text + btn_text = button_text or "🎁 ΠŸΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ" + + semaphore = asyncio.Semaphore(20) + + async def send_single(user: User, offer: DiscountOffer) -> bool: + async with semaphore: + try: + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + build_miniapp_or_callback_button( + text=btn_text, + callback_data=f"claim_discount_{offer.id}", + ) + ], + [ + InlineKeyboardButton( + text="❌ Π—Π°ΠΊΡ€Ρ‹Ρ‚ΡŒ", + callback_data="promo_offer_close", + ) + ], + ] + ) + + await bot.send_message( + chat_id=user.telegram_id, + text=text, + reply_markup=keyboard, + ) + return True + except (TelegramForbiddenError, TelegramBadRequest) as exc: + logger.warning( + "Failed to send promo notification to user %s: %s", + user.telegram_id, + exc, + ) + return False + except Exception as exc: + logger.error( + "Error sending promo notification to user %s: %s", + user.telegram_id, + exc, + ) + return False + + # Send in batches + batch_size = 50 + for i in range(0, len(offers_to_notify), batch_size): + batch = offers_to_notify[i : i + batch_size] + tasks = [send_single(user, offer) for user, offer in batch] + results = await asyncio.gather(*tasks, return_exceptions=True) + + for result in results: + if isinstance(result, bool) and result: + sent += 1 + else: + failed += 1 + + # Small delay between batches + if i + batch_size < len(offers_to_notify): + await asyncio.sleep(0.1) + + # Close bot session + await bot.session.close() + + return sent, failed + + @router.post("/broadcast", response_model=PromoOfferBroadcastResponse, status_code=status.HTTP_201_CREATED) async def broadcast_offer( payload: PromoOfferBroadcastRequest, admin: User = Depends(get_current_admin_user), db: AsyncSession = Depends(get_cabinet_db), ) -> PromoOfferBroadcastResponse: - """Broadcast promo offer to users.""" + """Broadcast promo offer to users with optional Telegram notification.""" recipients: dict[int, User] = {} # Resolve target segment @@ -386,8 +527,10 @@ async def broadcast_offer( "No recipients: specify target or user", ) - # Create offers for all recipients + # Create offers for all recipients and collect (user, offer) pairs created_offers = 0 + offers_to_notify: List[tuple[User, DiscountOffer]] = [] + for recipient in recipients.values(): offer = await upsert_discount_offer( db, @@ -402,11 +545,28 @@ async def broadcast_offer( ) if offer: created_offers += 1 + offers_to_notify.append((recipient, offer)) + + # Send Telegram notifications if requested + notifications_sent = 0 + notifications_failed = 0 + + if payload.send_notification and offers_to_notify: + notifications_sent, notifications_failed = await _send_promo_notifications( + offers_to_notify=offers_to_notify, + message_text=payload.message_text, + button_text=payload.button_text, + discount_percent=payload.discount_percent, + bonus_amount_kopeks=payload.bonus_amount_kopeks, + valid_hours=payload.valid_hours, + ) return PromoOfferBroadcastResponse( created_offers=created_offers, user_ids=list(recipients.keys()), target=payload.target, + notifications_sent=notifications_sent, + notifications_failed=notifications_failed, )