9a2aea038a
- Add pyproject.toml with uv and ruff configuration - Pin Python version to 3.13 via .python-version - Add Makefile commands: lint, format, fix - Apply ruff formatting to entire codebase - Remove unused imports (base64 in yookassa/simple_subscription) - Update .gitignore for new config files
623 lines
20 KiB
Python
623 lines
20 KiB
Python
"""Admin promo offers routes for cabinet."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
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,
|
|
upsert_discount_offer,
|
|
)
|
|
from app.database.crud.promo_offer_log import list_promo_offer_logs
|
|
from app.database.crud.promo_offer_template import (
|
|
ensure_default_templates,
|
|
get_promo_offer_template_by_id,
|
|
list_promo_offer_templates,
|
|
update_promo_offer_template,
|
|
)
|
|
from app.database.crud.user import get_user_by_email, 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'])
|
|
|
|
|
|
# ============== Schemas ==============
|
|
|
|
|
|
class PromoOfferUserInfo(BaseModel):
|
|
id: int
|
|
telegram_id: int | None = None # Can be None for email-only users
|
|
email: str | None = None
|
|
username: str | None = None
|
|
first_name: str | None = None
|
|
last_name: str | None = None
|
|
full_name: str | None = None
|
|
|
|
|
|
class PromoOfferResponse(BaseModel):
|
|
id: int
|
|
user_id: int
|
|
subscription_id: int | None = None
|
|
notification_type: str | None = None
|
|
discount_percent: int | None = None
|
|
bonus_amount_kopeks: int | None = None
|
|
expires_at: datetime | None = None
|
|
claimed_at: datetime | None = None
|
|
is_active: bool
|
|
effect_type: str | None = None
|
|
extra_data: dict[str, Any] = Field(default_factory=dict)
|
|
created_at: datetime | None = None
|
|
updated_at: datetime | None = None
|
|
user: PromoOfferUserInfo | None = None
|
|
|
|
|
|
class PromoOfferListResponse(BaseModel):
|
|
items: list[PromoOfferResponse]
|
|
total: int
|
|
limit: int
|
|
offset: int
|
|
|
|
|
|
class PromoOfferTemplateResponse(BaseModel):
|
|
id: int
|
|
name: str
|
|
offer_type: str
|
|
message_text: str
|
|
button_text: str
|
|
valid_hours: int
|
|
discount_percent: int
|
|
bonus_amount_kopeks: int
|
|
active_discount_hours: int | None = None
|
|
test_duration_hours: int | None = None
|
|
test_squad_uuids: list[str] = Field(default_factory=list)
|
|
is_active: bool
|
|
created_by: int | None = None
|
|
created_at: datetime | None = None
|
|
updated_at: datetime | None = None
|
|
|
|
|
|
class PromoOfferTemplateListResponse(BaseModel):
|
|
items: list[PromoOfferTemplateResponse]
|
|
|
|
|
|
class PromoOfferTemplateUpdateRequest(BaseModel):
|
|
name: str | None = None
|
|
message_text: str | None = None
|
|
button_text: str | None = None
|
|
valid_hours: int | None = Field(None, ge=1)
|
|
discount_percent: int | None = Field(None, ge=0)
|
|
bonus_amount_kopeks: int | None = Field(None, ge=0)
|
|
active_discount_hours: int | None = Field(None, ge=1)
|
|
test_duration_hours: int | None = Field(None, ge=1)
|
|
test_squad_uuids: list[str] | None = None
|
|
is_active: bool | None = None
|
|
|
|
|
|
class PromoOfferBroadcastRequest(BaseModel):
|
|
notification_type: str = Field(..., min_length=1)
|
|
valid_hours: int = Field(..., ge=1)
|
|
discount_percent: int = Field(0, ge=0)
|
|
bonus_amount_kopeks: int = Field(0, ge=0)
|
|
effect_type: str = Field('percent_discount', min_length=1)
|
|
extra_data: dict[str, Any] = Field(default_factory=dict)
|
|
target: str | None = None
|
|
user_id: int | None = None
|
|
telegram_id: int | None = None
|
|
email: str | None = Field(None, description='User email (for email-only users)')
|
|
# Telegram notification options
|
|
send_notification: bool = Field(False, description='Send Telegram notification to users')
|
|
message_text: str | None = Field(None, description='Custom message text (HTML)')
|
|
button_text: str | None = Field(None, description='Button text')
|
|
|
|
|
|
class PromoOfferBroadcastResponse(BaseModel):
|
|
created_offers: int
|
|
user_ids: list[int]
|
|
target: str | None = None
|
|
notifications_sent: int = 0
|
|
notifications_failed: int = 0
|
|
|
|
|
|
class PromoOfferLogOfferInfo(BaseModel):
|
|
id: int
|
|
notification_type: str | None = None
|
|
discount_percent: int | None = None
|
|
bonus_amount_kopeks: int | None = None
|
|
effect_type: str | None = None
|
|
expires_at: datetime | None = None
|
|
claimed_at: datetime | None = None
|
|
is_active: bool | None = None
|
|
|
|
|
|
class PromoOfferLogResponse(BaseModel):
|
|
id: int
|
|
user_id: int | None = None
|
|
offer_id: int | None = None
|
|
action: str
|
|
source: str | None = None
|
|
percent: int | None = None
|
|
effect_type: str | None = None
|
|
details: dict[str, Any] = Field(default_factory=dict)
|
|
created_at: datetime
|
|
user: PromoOfferUserInfo | None = None
|
|
offer: PromoOfferLogOfferInfo | None = None
|
|
|
|
|
|
class PromoOfferLogListResponse(BaseModel):
|
|
items: list[PromoOfferLogResponse]
|
|
total: int
|
|
limit: int
|
|
offset: int
|
|
|
|
|
|
# ============== Helpers ==============
|
|
|
|
|
|
def _serialize_user(user: User | None) -> PromoOfferUserInfo | None:
|
|
if not user:
|
|
return None
|
|
return PromoOfferUserInfo(
|
|
id=user.id,
|
|
telegram_id=user.telegram_id,
|
|
email=user.email,
|
|
username=user.username,
|
|
first_name=user.first_name,
|
|
last_name=user.last_name,
|
|
full_name=getattr(user, 'full_name', None),
|
|
)
|
|
|
|
|
|
def _serialize_offer(offer: DiscountOffer) -> PromoOfferResponse:
|
|
return PromoOfferResponse(
|
|
id=offer.id,
|
|
user_id=offer.user_id,
|
|
subscription_id=offer.subscription_id,
|
|
notification_type=offer.notification_type,
|
|
discount_percent=offer.discount_percent,
|
|
bonus_amount_kopeks=offer.bonus_amount_kopeks,
|
|
expires_at=offer.expires_at,
|
|
claimed_at=offer.claimed_at,
|
|
is_active=offer.is_active,
|
|
effect_type=offer.effect_type,
|
|
extra_data=offer.extra_data or {},
|
|
created_at=offer.created_at,
|
|
updated_at=offer.updated_at,
|
|
user=_serialize_user(getattr(offer, 'user', None)),
|
|
)
|
|
|
|
|
|
def _serialize_template(template: PromoOfferTemplate) -> PromoOfferTemplateResponse:
|
|
return PromoOfferTemplateResponse(
|
|
id=template.id,
|
|
name=template.name,
|
|
offer_type=template.offer_type,
|
|
message_text=template.message_text,
|
|
button_text=template.button_text,
|
|
valid_hours=template.valid_hours,
|
|
discount_percent=template.discount_percent,
|
|
bonus_amount_kopeks=template.bonus_amount_kopeks,
|
|
active_discount_hours=template.active_discount_hours,
|
|
test_duration_hours=template.test_duration_hours,
|
|
test_squad_uuids=[str(uuid) for uuid in (template.test_squad_uuids or [])],
|
|
is_active=template.is_active,
|
|
created_by=template.created_by,
|
|
created_at=template.created_at,
|
|
updated_at=template.updated_at,
|
|
)
|
|
|
|
|
|
def _serialize_log(entry: PromoOfferLog) -> PromoOfferLogResponse:
|
|
user_info = _serialize_user(getattr(entry, 'user', None))
|
|
|
|
offer = getattr(entry, 'offer', None)
|
|
offer_info: PromoOfferLogOfferInfo | None = None
|
|
if offer:
|
|
offer_info = PromoOfferLogOfferInfo(
|
|
id=offer.id,
|
|
notification_type=offer.notification_type,
|
|
discount_percent=offer.discount_percent,
|
|
bonus_amount_kopeks=offer.bonus_amount_kopeks,
|
|
effect_type=offer.effect_type,
|
|
expires_at=offer.expires_at,
|
|
claimed_at=offer.claimed_at,
|
|
is_active=offer.is_active,
|
|
)
|
|
|
|
return PromoOfferLogResponse(
|
|
id=entry.id,
|
|
user_id=entry.user_id,
|
|
offer_id=entry.offer_id,
|
|
action=entry.action,
|
|
source=entry.source,
|
|
percent=entry.percent,
|
|
effect_type=entry.effect_type,
|
|
details=entry.details or {},
|
|
created_at=entry.created_at,
|
|
user=user_info,
|
|
offer=offer_info,
|
|
)
|
|
|
|
|
|
async def _resolve_target_users(db: AsyncSession, target: str) -> list[User]:
|
|
normalized = target.strip().lower()
|
|
if normalized.startswith('custom_'):
|
|
criteria = normalized[len('custom_') :]
|
|
return await get_custom_users(db, criteria)
|
|
return await get_target_users(db, normalized)
|
|
|
|
|
|
# ============== Template Endpoints ==============
|
|
|
|
|
|
@router.get('/templates', response_model=PromoOfferTemplateListResponse)
|
|
async def list_templates(
|
|
admin: User = Depends(get_current_admin_user),
|
|
db: AsyncSession = Depends(get_cabinet_db),
|
|
) -> PromoOfferTemplateListResponse:
|
|
"""Get list of promo offer templates."""
|
|
templates = await list_promo_offer_templates(db)
|
|
|
|
# Initialize default templates if none exist
|
|
if not templates:
|
|
templates = await ensure_default_templates(db, created_by=admin.id)
|
|
|
|
return PromoOfferTemplateListResponse(items=[_serialize_template(template) for template in templates])
|
|
|
|
|
|
@router.get('/templates/{template_id}', response_model=PromoOfferTemplateResponse)
|
|
async def get_template(
|
|
template_id: int,
|
|
admin: User = Depends(get_current_admin_user),
|
|
db: AsyncSession = Depends(get_cabinet_db),
|
|
) -> PromoOfferTemplateResponse:
|
|
"""Get a promo offer template."""
|
|
template = await get_promo_offer_template_by_id(db, template_id)
|
|
if not template:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Template not found')
|
|
return _serialize_template(template)
|
|
|
|
|
|
@router.patch('/templates/{template_id}', response_model=PromoOfferTemplateResponse)
|
|
async def update_template(
|
|
template_id: int,
|
|
payload: PromoOfferTemplateUpdateRequest,
|
|
admin: User = Depends(get_current_admin_user),
|
|
db: AsyncSession = Depends(get_cabinet_db),
|
|
) -> PromoOfferTemplateResponse:
|
|
"""Update a promo offer template."""
|
|
template = await get_promo_offer_template_by_id(db, template_id)
|
|
if not template:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Template not found')
|
|
|
|
if payload.test_squad_uuids is not None:
|
|
normalized_squads = [str(uuid).strip() for uuid in payload.test_squad_uuids if str(uuid).strip()]
|
|
else:
|
|
normalized_squads = None
|
|
|
|
updated_template = await update_promo_offer_template(
|
|
db,
|
|
template,
|
|
name=payload.name,
|
|
message_text=payload.message_text,
|
|
button_text=payload.button_text,
|
|
valid_hours=payload.valid_hours,
|
|
discount_percent=payload.discount_percent,
|
|
bonus_amount_kopeks=payload.bonus_amount_kopeks,
|
|
active_discount_hours=payload.active_discount_hours,
|
|
test_duration_hours=payload.test_duration_hours,
|
|
test_squad_uuids=normalized_squads,
|
|
is_active=payload.is_active,
|
|
)
|
|
|
|
return _serialize_template(updated_template)
|
|
|
|
|
|
# ============== Offer Endpoints ==============
|
|
|
|
|
|
@router.get('', response_model=PromoOfferListResponse)
|
|
async def list_offers(
|
|
admin: User = Depends(get_current_admin_user),
|
|
db: AsyncSession = Depends(get_cabinet_db),
|
|
limit: int = Query(50, ge=1, le=200),
|
|
offset: int = Query(0, ge=0),
|
|
user_id: int | None = Query(None, ge=1),
|
|
is_active: bool | None = Query(None),
|
|
) -> PromoOfferListResponse:
|
|
"""Get list of promo offers."""
|
|
offers = await list_discount_offers(
|
|
db,
|
|
offset=offset,
|
|
limit=limit,
|
|
user_id=user_id,
|
|
is_active=is_active,
|
|
)
|
|
total = await count_discount_offers(
|
|
db,
|
|
user_id=user_id,
|
|
is_active=is_active,
|
|
)
|
|
|
|
return PromoOfferListResponse(
|
|
items=[_serialize_offer(offer) for offer in offers],
|
|
total=total,
|
|
limit=limit,
|
|
offset=offset,
|
|
)
|
|
|
|
|
|
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 = ['🎁 <b>Специальное предложение для вас!</b>\n']
|
|
|
|
if discount_percent > 0:
|
|
lines.append(f'🔥 Скидка <b>{discount_percent}%</b> на подписку')
|
|
if bonus_amount_kopeks > 0:
|
|
bonus_rub = bonus_amount_kopeks / 100
|
|
lines.append(f'💰 Бонус <b>{bonus_rub:.0f}₽</b> на баланс')
|
|
|
|
lines.append(f'\n⏰ Предложение действует <b>{valid_hours} ч.</b>')
|
|
lines.append('\nНажмите кнопку ниже, чтобы активировать!')
|
|
|
|
return '\n'.join(lines)
|
|
|
|
|
|
async def _send_promo_notifications(
|
|
offers_to_notify: list[tuple[User, DiscountOffer]],
|
|
message_text: str | None,
|
|
button_text: str | None,
|
|
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:
|
|
# Skip email-only users (no telegram_id)
|
|
if not user.telegram_id:
|
|
logger.debug(f'Skipping promo notification for email-only user {user.id}')
|
|
return False
|
|
|
|
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 with optional Telegram notification."""
|
|
recipients: dict[int, User] = {}
|
|
|
|
# Resolve target segment
|
|
if payload.target:
|
|
users = await _resolve_target_users(db, payload.target)
|
|
recipients.update({user.id: user for user in users if user and user.id})
|
|
|
|
# Resolve specific user
|
|
target_user_id = payload.user_id
|
|
user: User | None = None
|
|
|
|
if payload.telegram_id is not None:
|
|
user = await get_user_by_telegram_id(db, payload.telegram_id)
|
|
if not user:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found by telegram_id')
|
|
if target_user_id and target_user_id != user.id:
|
|
raise HTTPException(
|
|
status.HTTP_400_BAD_REQUEST,
|
|
'Provided user_id does not match telegram_id',
|
|
)
|
|
target_user_id = user.id
|
|
|
|
# Support email lookup for email-only users
|
|
if payload.email is not None and user is None:
|
|
user = await get_user_by_email(db, payload.email)
|
|
if not user:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found by email')
|
|
if target_user_id and target_user_id != user.id:
|
|
raise HTTPException(
|
|
status.HTTP_400_BAD_REQUEST,
|
|
'Provided user_id does not match email',
|
|
)
|
|
target_user_id = user.id
|
|
|
|
if target_user_id is not None:
|
|
if user is None:
|
|
user = await db.get(User, target_user_id)
|
|
if not user:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
|
|
recipients[target_user_id] = user
|
|
|
|
if not recipients:
|
|
raise HTTPException(
|
|
status.HTTP_400_BAD_REQUEST,
|
|
'No recipients: specify target or user',
|
|
)
|
|
|
|
# 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,
|
|
user_id=recipient.id,
|
|
subscription_id=None,
|
|
notification_type=payload.notification_type.strip(),
|
|
discount_percent=payload.discount_percent,
|
|
bonus_amount_kopeks=payload.bonus_amount_kopeks,
|
|
valid_hours=payload.valid_hours,
|
|
effect_type=payload.effect_type,
|
|
extra_data=payload.extra_data,
|
|
)
|
|
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,
|
|
)
|
|
|
|
|
|
# ============== Log Endpoints ==============
|
|
|
|
|
|
@router.get('/logs', response_model=PromoOfferLogListResponse)
|
|
async def get_logs(
|
|
admin: User = Depends(get_current_admin_user),
|
|
db: AsyncSession = Depends(get_cabinet_db),
|
|
limit: int = Query(50, ge=1, le=200),
|
|
offset: int = Query(0, ge=0),
|
|
user_id: int | None = Query(None, ge=1),
|
|
action: str | None = Query(None, min_length=1),
|
|
) -> PromoOfferLogListResponse:
|
|
"""Get promo offer logs."""
|
|
logs, total = await list_promo_offer_logs(
|
|
db,
|
|
offset=offset,
|
|
limit=limit,
|
|
user_id=user_id,
|
|
action=action,
|
|
)
|
|
|
|
return PromoOfferLogListResponse(
|
|
items=[_serialize_log(entry) for entry in logs],
|
|
total=int(total),
|
|
limit=limit,
|
|
offset=offset,
|
|
)
|