diff --git a/app/cabinet/routes/admin_stats.py b/app/cabinet/routes/admin_stats.py index b13cb5ef..411cf599 100644 --- a/app/cabinet/routes/admin_stats.py +++ b/app/cabinet/routes/admin_stats.py @@ -13,11 +13,14 @@ from sqlalchemy import select, func, and_ from app.database.crud.subscription import get_subscriptions_statistics from app.database.crud.transaction import get_transactions_statistics, get_revenue_by_period from app.database.crud.server_squad import get_server_statistics +from app.database.crud.campaign import get_campaigns_list, get_campaign_statistics, get_campaigns_count from app.services.remnawave_service import RemnaWaveService -from app.config import settings from ..dependencies import get_cabinet_db, get_current_admin_user -from app.database.models import User, Subscription, Tariff, SubscriptionStatus +from app.database.models import ( + User, Subscription, Tariff, SubscriptionStatus, + Transaction, TransactionType, ReferralEarning, +) logger = logging.getLogger(__name__) @@ -36,6 +39,15 @@ class NodeStatus(BaseModel): users_online: int traffic_used_bytes: Optional[int] = None uptime: Optional[str] = None + xray_version: Optional[str] = None + node_version: Optional[str] = None + last_status_message: Optional[str] = None + xray_uptime: Optional[str] = None + is_xray_running: Optional[bool] = None + cpu_count: Optional[int] = None + cpu_model: Optional[str] = None + total_ram: Optional[str] = None + country_code: Optional[str] = None class NodesOverview(BaseModel): @@ -116,6 +128,81 @@ class DashboardStats(BaseModel): tariff_stats: Optional[TariffStats] = None +# ============ Extended Stats Schemas ============ + +class TopReferrerItem(BaseModel): + """Single referrer in top list.""" + user_id: int + telegram_id: int + username: Optional[str] = None + display_name: str + invited_count: int + invited_today: int = 0 + invited_week: int = 0 + invited_month: int = 0 + earnings_today_kopeks: int = 0 + earnings_week_kopeks: int = 0 + earnings_month_kopeks: int = 0 + earnings_total_kopeks: int = 0 + + +class TopReferrersResponse(BaseModel): + """Top referrers response.""" + by_earnings: List[TopReferrerItem] + by_invited: List[TopReferrerItem] + total_referrers: int + total_referrals: int + total_earnings_kopeks: int + + +class TopCampaignItem(BaseModel): + """Single campaign in top list.""" + id: int + name: str + start_parameter: str + bonus_type: str + is_active: bool + registrations: int + conversions: int + conversion_rate: float + total_revenue_kopeks: int + avg_revenue_per_user_kopeks: int + created_at: Optional[str] = None + + +class TopCampaignsResponse(BaseModel): + """Top campaigns response.""" + campaigns: List[TopCampaignItem] + total_campaigns: int + total_registrations: int + total_revenue_kopeks: int + + +class RecentPaymentItem(BaseModel): + """Single recent payment.""" + id: int + user_id: int + telegram_id: int + username: Optional[str] = None + display_name: str + amount_kopeks: int + amount_rubles: float + type: str + type_display: str + payment_method: Optional[str] = None + description: Optional[str] = None + created_at: str + is_completed: bool + + +class RecentPaymentsResponse(BaseModel): + """Recent payments response.""" + payments: List[RecentPaymentItem] + total_count: int + total_today_kopeks: int + total_week_kopeks: int + + # ============ Routes ============ @router.get("/dashboard", response_model=DashboardStats) @@ -300,6 +387,15 @@ async def _get_nodes_overview() -> NodesOverview: users_online=n.get("users_online", 0) or 0, traffic_used_bytes=n.get("traffic_used_bytes"), uptime=n.get("uptime"), + xray_version=n.get("xray_version"), + node_version=n.get("node_version"), + last_status_message=n.get("last_status_message"), + xray_uptime=n.get("xray_uptime"), + is_xray_running=n.get("is_xray_running"), + cpu_count=n.get("cpu_count"), + cpu_model=n.get("cpu_model"), + total_ram=n.get("total_ram"), + country_code=n.get("country_code"), ) for n in nodes ] @@ -426,3 +522,457 @@ async def _get_tariff_stats(db: AsyncSession) -> Optional[TariffStats]: except Exception as e: logger.error(f"Failed to get tariff stats: {e}", exc_info=True) return None + + +# ============ Extended Stats Routes ============ + +@router.get("/referrals/top", response_model=TopReferrersResponse) +async def get_top_referrers( + limit: int = 20, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get top referrers with earnings breakdown by period.""" + try: + now = datetime.utcnow() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + week_ago = now - timedelta(days=7) + month_ago = now - timedelta(days=30) + + # Get all referrers with their stats + referrers_query = await db.execute( + select( + User.referred_by_id.label('referrer_id'), + func.count(User.id).label('total_invited') + ) + .where(User.referred_by_id.isnot(None)) + .group_by(User.referred_by_id) + ) + referrers_data = {row.referrer_id: {'total_invited': row.total_invited} for row in referrers_query} + + # Get invited counts by period for each referrer + # Today + today_invited_query = await db.execute( + select( + User.referred_by_id.label('referrer_id'), + func.count(User.id).label('count') + ) + .where( + and_( + User.referred_by_id.isnot(None), + User.created_at >= today_start + ) + ) + .group_by(User.referred_by_id) + ) + for row in today_invited_query: + if row.referrer_id in referrers_data: + referrers_data[row.referrer_id]['invited_today'] = row.count + + # Week + week_invited_query = await db.execute( + select( + User.referred_by_id.label('referrer_id'), + func.count(User.id).label('count') + ) + .where( + and_( + User.referred_by_id.isnot(None), + User.created_at >= week_ago + ) + ) + .group_by(User.referred_by_id) + ) + for row in week_invited_query: + if row.referrer_id in referrers_data: + referrers_data[row.referrer_id]['invited_week'] = row.count + + # Month + month_invited_query = await db.execute( + select( + User.referred_by_id.label('referrer_id'), + func.count(User.id).label('count') + ) + .where( + and_( + User.referred_by_id.isnot(None), + User.created_at >= month_ago + ) + ) + .group_by(User.referred_by_id) + ) + for row in month_invited_query: + if row.referrer_id in referrers_data: + referrers_data[row.referrer_id]['invited_month'] = row.count + + # Get earnings from ReferralEarning table + # Total earnings + total_earnings_query = await db.execute( + select( + ReferralEarning.user_id.label('referrer_id'), + func.sum(ReferralEarning.amount_kopeks).label('total') + ) + .group_by(ReferralEarning.user_id) + ) + for row in total_earnings_query: + if row.referrer_id in referrers_data: + referrers_data[row.referrer_id]['earnings_total'] = row.total or 0 + + # Today earnings + today_earnings_query = await db.execute( + select( + ReferralEarning.user_id.label('referrer_id'), + func.sum(ReferralEarning.amount_kopeks).label('total') + ) + .where(ReferralEarning.created_at >= today_start) + .group_by(ReferralEarning.user_id) + ) + for row in today_earnings_query: + if row.referrer_id in referrers_data: + referrers_data[row.referrer_id]['earnings_today'] = row.total or 0 + + # Week earnings + week_earnings_query = await db.execute( + select( + ReferralEarning.user_id.label('referrer_id'), + func.sum(ReferralEarning.amount_kopeks).label('total') + ) + .where(ReferralEarning.created_at >= week_ago) + .group_by(ReferralEarning.user_id) + ) + for row in week_earnings_query: + if row.referrer_id in referrers_data: + referrers_data[row.referrer_id]['earnings_week'] = row.total or 0 + + # Month earnings + month_earnings_query = await db.execute( + select( + ReferralEarning.user_id.label('referrer_id'), + func.sum(ReferralEarning.amount_kopeks).label('total') + ) + .where(ReferralEarning.created_at >= month_ago) + .group_by(ReferralEarning.user_id) + ) + for row in month_earnings_query: + if row.referrer_id in referrers_data: + referrers_data[row.referrer_id]['earnings_month'] = row.total or 0 + + # Also add REFERRAL_REWARD transactions + trans_total_query = await db.execute( + select( + Transaction.user_id.label('referrer_id'), + func.sum(Transaction.amount_kopeks).label('total') + ) + .where(Transaction.type == TransactionType.REFERRAL_REWARD.value) + .group_by(Transaction.user_id) + ) + for row in trans_total_query: + if row.referrer_id in referrers_data: + referrers_data[row.referrer_id]['earnings_total'] = \ + referrers_data[row.referrer_id].get('earnings_total', 0) + (row.total or 0) + + trans_today_query = await db.execute( + select( + Transaction.user_id.label('referrer_id'), + func.sum(Transaction.amount_kopeks).label('total') + ) + .where( + and_( + Transaction.type == TransactionType.REFERRAL_REWARD.value, + Transaction.created_at >= today_start + ) + ) + .group_by(Transaction.user_id) + ) + for row in trans_today_query: + if row.referrer_id in referrers_data: + referrers_data[row.referrer_id]['earnings_today'] = \ + referrers_data[row.referrer_id].get('earnings_today', 0) + (row.total or 0) + + trans_week_query = await db.execute( + select( + Transaction.user_id.label('referrer_id'), + func.sum(Transaction.amount_kopeks).label('total') + ) + .where( + and_( + Transaction.type == TransactionType.REFERRAL_REWARD.value, + Transaction.created_at >= week_ago + ) + ) + .group_by(Transaction.user_id) + ) + for row in trans_week_query: + if row.referrer_id in referrers_data: + referrers_data[row.referrer_id]['earnings_week'] = \ + referrers_data[row.referrer_id].get('earnings_week', 0) + (row.total or 0) + + trans_month_query = await db.execute( + select( + Transaction.user_id.label('referrer_id'), + func.sum(Transaction.amount_kopeks).label('total') + ) + .where( + and_( + Transaction.type == TransactionType.REFERRAL_REWARD.value, + Transaction.created_at >= month_ago + ) + ) + .group_by(Transaction.user_id) + ) + for row in trans_month_query: + if row.referrer_id in referrers_data: + referrers_data[row.referrer_id]['earnings_month'] = \ + referrers_data[row.referrer_id].get('earnings_month', 0) + (row.total or 0) + + # Get user info for all referrers + referrer_ids = list(referrers_data.keys()) + if referrer_ids: + users_query = await db.execute( + select(User.id, User.telegram_id, User.username, User.first_name, User.last_name) + .where(User.id.in_(referrer_ids)) + ) + users_info = {u.id: u for u in users_query} + else: + users_info = {} + + # Build referrer items + referrer_items = [] + for referrer_id, data in referrers_data.items(): + user = users_info.get(referrer_id) + if not user: + continue + + display_name = "" + if user.first_name: + display_name = user.first_name + if user.last_name: + display_name += f" {user.last_name}" + elif user.username: + display_name = f"@{user.username}" + else: + display_name = f"ID{user.telegram_id}" + + referrer_items.append(TopReferrerItem( + user_id=user.id, + telegram_id=user.telegram_id, + username=user.username, + display_name=display_name, + invited_count=data.get('total_invited', 0), + invited_today=data.get('invited_today', 0), + invited_week=data.get('invited_week', 0), + invited_month=data.get('invited_month', 0), + earnings_today_kopeks=data.get('earnings_today', 0), + earnings_week_kopeks=data.get('earnings_week', 0), + earnings_month_kopeks=data.get('earnings_month', 0), + earnings_total_kopeks=data.get('earnings_total', 0), + )) + + # Sort by earnings and by invited + by_earnings = sorted(referrer_items, key=lambda x: x.earnings_total_kopeks, reverse=True)[:limit] + by_invited = sorted(referrer_items, key=lambda x: x.invited_count, reverse=True)[:limit] + + # Calculate totals + total_referrers = len(referrer_items) + total_referrals = sum(r.invited_count for r in referrer_items) + total_earnings = sum(r.earnings_total_kopeks for r in referrer_items) + + return TopReferrersResponse( + by_earnings=by_earnings, + by_invited=by_invited, + total_referrers=total_referrers, + total_referrals=total_referrals, + total_earnings_kopeks=total_earnings, + ) + + except Exception as e: + logger.error(f"Failed to get top referrers: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to load referrers statistics", + ) + + +@router.get("/campaigns/top", response_model=TopCampaignsResponse) +async def get_top_campaigns( + limit: int = 20, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get top advertising campaigns with statistics.""" + try: + # Get all campaigns + campaigns = await get_campaigns_list(db, offset=0, limit=100, include_inactive=True) + + campaign_items = [] + total_registrations = 0 + total_revenue = 0 + + for campaign in campaigns: + stats = await get_campaign_statistics(db, campaign.id) + + campaign_items.append(TopCampaignItem( + id=campaign.id, + name=campaign.name, + start_parameter=campaign.start_parameter, + bonus_type=campaign.bonus_type, + is_active=campaign.is_active, + registrations=stats.get("registrations", 0), + conversions=stats.get("conversion_count", 0), + conversion_rate=stats.get("conversion_rate", 0.0), + total_revenue_kopeks=stats.get("total_revenue_kopeks", 0), + avg_revenue_per_user_kopeks=stats.get("avg_revenue_per_user_kopeks", 0), + created_at=campaign.created_at.isoformat() if campaign.created_at else None, + )) + + total_registrations += stats.get("registrations", 0) + total_revenue += stats.get("total_revenue_kopeks", 0) + + # Sort by revenue + campaign_items.sort(key=lambda x: x.total_revenue_kopeks, reverse=True) + + total_campaigns = await get_campaigns_count(db) + + return TopCampaignsResponse( + campaigns=campaign_items[:limit], + total_campaigns=total_campaigns, + total_registrations=total_registrations, + total_revenue_kopeks=total_revenue, + ) + + except Exception as e: + logger.error(f"Failed to get top campaigns: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to load campaigns statistics", + ) + + +@router.get("/payments/recent", response_model=RecentPaymentsResponse) +async def get_recent_payments( + limit: int = 50, + admin: User = Depends(get_current_admin_user), + db: AsyncSession = Depends(get_cabinet_db), +): + """Get recent payments with user info.""" + try: + now = datetime.utcnow() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + week_ago = now - timedelta(days=7) + + # Get recent transactions (deposits and subscription payments) + transactions_query = await db.execute( + select(Transaction) + .where( + Transaction.type.in_([ + TransactionType.DEPOSIT.value, + TransactionType.SUBSCRIPTION_PAYMENT.value, + ]) + ) + .order_by(Transaction.created_at.desc()) + .limit(limit) + ) + transactions = transactions_query.scalars().all() + + # Get user info for all transactions + user_ids = list(set(t.user_id for t in transactions)) + if user_ids: + users_query = await db.execute( + select(User.id, User.telegram_id, User.username, User.first_name, User.last_name) + .where(User.id.in_(user_ids)) + ) + users_info = {u.id: u for u in users_query} + else: + users_info = {} + + # Type display names + type_display = { + TransactionType.DEPOSIT.value: "Пополнение", + TransactionType.SUBSCRIPTION_PAYMENT.value: "Оплата подписки", + TransactionType.WITHDRAWAL.value: "Вывод", + TransactionType.REFUND.value: "Возврат", + TransactionType.REFERRAL_REWARD.value: "Реферальный бонус", + TransactionType.POLL_REWARD.value: "Награда за опрос", + } + + payment_items = [] + for trans in transactions: + user = users_info.get(trans.user_id) + if not user: + continue + + display_name = "" + if user.first_name: + display_name = user.first_name + if user.last_name: + display_name += f" {user.last_name}" + elif user.username: + display_name = f"@{user.username}" + else: + display_name = f"ID{user.telegram_id}" + + payment_items.append(RecentPaymentItem( + id=trans.id, + user_id=user.id, + telegram_id=user.telegram_id, + username=user.username, + display_name=display_name, + amount_kopeks=trans.amount_kopeks, + amount_rubles=trans.amount_kopeks / 100, + type=trans.type, + type_display=type_display.get(trans.type, trans.type), + payment_method=trans.payment_method, + description=trans.description, + created_at=trans.created_at.isoformat() if trans.created_at else "", + is_completed=trans.is_completed, + )) + + # Calculate totals + total_count_result = await db.execute( + select(func.count(Transaction.id)) + .where( + Transaction.type.in_([ + TransactionType.DEPOSIT.value, + TransactionType.SUBSCRIPTION_PAYMENT.value, + ]) + ) + ) + total_count = total_count_result.scalar() or 0 + + today_total_result = await db.execute( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) + .where( + and_( + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.is_completed == True, + Transaction.created_at >= today_start + ) + ) + ) + total_today = today_total_result.scalar() or 0 + + week_total_result = await db.execute( + select(func.coalesce(func.sum(Transaction.amount_kopeks), 0)) + .where( + and_( + Transaction.type == TransactionType.DEPOSIT.value, + Transaction.is_completed == True, + Transaction.created_at >= week_ago + ) + ) + ) + total_week = week_total_result.scalar() or 0 + + return RecentPaymentsResponse( + payments=payment_items, + total_count=total_count, + total_today_kopeks=total_today, + total_week_kopeks=total_week, + ) + + except Exception as e: + logger.error(f"Failed to get recent payments: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to load recent payments", + )