Files
remnawave-bedolaga-telegram…/app/database/crud/subscription_conversion.py
Fringg 1f0fef114b refactor: complete structlog migration with contextvars, kwargs, and logging hardening
- Add ContextVarsMiddleware for automatic user_id/chat_id/username binding
  via structlog contextvars (aiogram) and http_method/http_path (FastAPI)
- Use bound_contextvars() context manager instead of clear_contextvars()
  to safely restore previous state instead of wiping all context
- Register ContextVarsMiddleware as outermost middleware (before GlobalError)
  so all error logs include user context
- Replace structlog.get_logger() with structlog.get_logger(__name__) across
  270 calls in 265 files for meaningful logger names
- Switch wrapper_class from BoundLogger to make_filtering_bound_logger()
  for pre-processor level filtering (performance optimization)
- Migrate 1411 %-style positional arg logger calls to structlog kwargs
  style across 161 files via AST script
- Migrate log_rotation_service.py from stdlib logging to structlog
- Add payment module prefixes to TelegramNotifierProcessor.IGNORED_LOGGER_PREFIXES
  and ExcludePaymentFilter.PAYMENT_MODULES to prevent payment data leaking
  to Telegram notifications and general log files
- Fix LoggingMiddleware: add from_user null-safety for channel posts,
  switch time.time() to time.monotonic() for duration measurement
- Remove duplicate logger assignments in purchase.py, config.py,
  inline.py, and admin/payments.py
2026-02-16 09:18:12 +03:00

115 lines
4.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from datetime import datetime, timedelta
import structlog
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import SubscriptionConversion, User
logger = structlog.get_logger(__name__)
async def create_subscription_conversion(
db: AsyncSession,
user_id: int,
trial_duration_days: int,
payment_method: str,
first_payment_amount_kopeks: int,
first_paid_period_days: int,
) -> SubscriptionConversion:
conversion = SubscriptionConversion(
user_id=user_id,
converted_at=datetime.utcnow(),
trial_duration_days=trial_duration_days,
payment_method=payment_method,
first_payment_amount_kopeks=first_payment_amount_kopeks,
first_paid_period_days=first_paid_period_days,
)
db.add(conversion)
await db.commit()
await db.refresh(conversion)
logger.info(
'✅ Создана запись о конверсии для пользователя дн. → дн. за ₽',
user_id=user_id,
trial_duration_days=trial_duration_days,
first_paid_period_days=first_paid_period_days,
first_payment_amount_kopeks=first_payment_amount_kopeks / 100,
)
return conversion
async def get_conversion_by_user_id(db: AsyncSession, user_id: int) -> SubscriptionConversion | None:
result = await db.execute(
select(SubscriptionConversion)
.where(SubscriptionConversion.user_id == user_id)
.order_by(SubscriptionConversion.converted_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def get_conversion_statistics(db: AsyncSession) -> dict:
from app.database.models import Subscription
# Получаем количество записей о конверсиях в таблице
total_conversions_result = await db.execute(select(func.count(SubscriptionConversion.id)))
total_conversions = total_conversions_result.scalar() or 0
# Подсчитываем пользователей с платными подписками
users_with_paid_result = await db.execute(select(func.count(User.id)).where(User.has_had_paid_subscription == True))
users_with_paid = users_with_paid_result.scalar() or 0
# Подсчитываем всех пользователей с подписками (использовавших триал)
# Считаем что все новые пользователи начинают с триала
total_users_with_subscriptions_result = await db.execute(select(func.count(func.distinct(Subscription.user_id))))
total_users_with_subscriptions = total_users_with_subscriptions_result.scalar() or 0
# Расчёт конверсии: (оплатившие) / (всего с подписками) * 100
# Это показывает какой % пользователей, получивших подписку, в итоге оплатили
if total_users_with_subscriptions > 0:
conversion_rate = round((users_with_paid / total_users_with_subscriptions) * 100, 1)
else:
conversion_rate = 0.0
avg_trial_duration_result = await db.execute(select(func.avg(SubscriptionConversion.trial_duration_days)))
avg_trial_duration = avg_trial_duration_result.scalar() or 0
avg_first_payment_result = await db.execute(select(func.avg(SubscriptionConversion.first_payment_amount_kopeks)))
avg_first_payment = avg_first_payment_result.scalar() or 0
month_ago = datetime.utcnow() - timedelta(days=30)
month_conversions_result = await db.execute(
select(func.count(SubscriptionConversion.id)).where(SubscriptionConversion.converted_at >= month_ago)
)
month_conversions = month_conversions_result.scalar() or 0
logger.info('📊 Статистика конверсий:')
logger.info('Всего пользователей с подписками', total_users_with_subscriptions=total_users_with_subscriptions)
logger.info('Оплативших подписку', users_with_paid=users_with_paid)
logger.info('Рассчитанная конверсия', conversion_rate=conversion_rate)
return {
'total_conversions': total_conversions,
'conversion_rate': conversion_rate,
'users_with_trial': total_users_with_subscriptions,
'converted_users': users_with_paid,
'avg_trial_duration_days': round(avg_trial_duration, 1),
'avg_first_payment_rubles': round((avg_first_payment or 0) / 100, 2),
'month_conversions': month_conversions,
}
async def get_users_had_trial_count(db: AsyncSession) -> int:
conversions_count_result = await db.execute(select(func.count(func.distinct(SubscriptionConversion.user_id))))
conversions_count = conversions_count_result.scalar()
paid_users_result = await db.execute(select(func.count(User.id)).where(User.has_had_paid_subscription == True))
paid_users_count = paid_users_result.scalar()
return max(conversions_count, paid_users_count)