Files
remnawave-bedolaga-telegram…/app/webapi/routes/partners.py
c0mrade 9a2aea038a chore: add uv package manager and ruff linter configuration
- 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
2026-01-24 17:45:27 +03:00

372 lines
13 KiB
Python

from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased, selectinload
from app.database.crud.referral import get_user_referral_stats
from app.database.crud.user import (
get_user_by_id,
get_user_by_telegram_id,
update_user,
)
from app.database.models import User
from app.services.partner_stats_service import PartnerStatsService
from app.utils.user_utils import (
get_detailed_referral_list,
get_effective_referral_commission_percent,
)
from ..dependencies import get_db_session, require_api_token
from ..schemas.partners import (
ChangeData,
DailyStats,
DailyStatsResponse,
EarningsByPeriod,
GlobalPartnerStats,
GlobalPartnerSummary,
NewReferralsByPeriod,
PartnerReferralCommissionUpdate,
PartnerReferralItem,
PartnerReferralList,
PartnerReferrerDetail,
PartnerReferrerItem,
PartnerReferrerListResponse,
PayoutsByPeriod,
PeriodChange,
PeriodComparisonResponse,
PeriodData,
ReferralsCountByPeriod,
ReferrerDetailedStats,
ReferrerSummary,
TopReferralItem,
TopReferralsResponse,
TopReferrerItem,
TopReferrersResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter()
def _apply_search_filter(query, search: str):
search_lower = f'%{search.lower()}%'
conditions = [
func.lower(User.username).like(search_lower),
func.lower(User.first_name).like(search_lower),
func.lower(User.last_name).like(search_lower),
func.lower(User.referral_code).like(search_lower),
]
if search.isdigit():
conditions.append(User.telegram_id == int(search))
conditions.append(User.id == int(search))
return query.where(or_(*conditions))
def _serialize_referrer(user: User, stats: dict) -> PartnerReferrerItem:
total_earned_kopeks = int(stats.get('total_earned_kopeks') or 0)
month_earned_kopeks = int(stats.get('month_earned_kopeks') or 0)
return PartnerReferrerItem(
id=user.id,
telegram_id=user.telegram_id,
username=user.username,
first_name=user.first_name,
last_name=user.last_name,
referral_code=user.referral_code,
referral_commission_percent=getattr(user, 'referral_commission_percent', None),
effective_referral_commission_percent=get_effective_referral_commission_percent(user),
invited_count=int(stats.get('invited_count') or 0),
active_referrals=int(stats.get('active_referrals') or 0),
total_earned_kopeks=total_earned_kopeks,
total_earned_rubles=round(total_earned_kopeks / 100, 2),
month_earned_kopeks=month_earned_kopeks,
month_earned_rubles=round(month_earned_kopeks / 100, 2),
created_at=user.created_at,
last_activity=user.last_activity,
)
def _serialize_referral_item(referral: dict) -> PartnerReferralItem:
balance_kopeks = int(referral.get('balance_kopeks') or 0)
total_earned_kopeks = int(referral.get('total_earned_kopeks') or 0)
# Handle email-only users (telegram_id=None)
raw_telegram_id = referral.get('telegram_id')
telegram_id = int(raw_telegram_id) if raw_telegram_id is not None else None
return PartnerReferralItem(
id=int(referral.get('id')),
telegram_id=telegram_id,
full_name=str(referral.get('full_name')),
username=referral.get('username'),
created_at=referral.get('created_at'),
last_activity=referral.get('last_activity'),
has_made_first_topup=bool(referral.get('has_made_first_topup', False)),
balance_kopeks=balance_kopeks,
balance_rubles=round(balance_kopeks / 100, 2),
total_earned_kopeks=total_earned_kopeks,
total_earned_rubles=round(total_earned_kopeks / 100, 2),
topups_count=int(referral.get('topups_count') or 0),
days_since_registration=int(referral.get('days_since_registration') or 0),
days_since_activity=referral.get('days_since_activity'),
status=str(referral.get('status') or 'inactive'),
)
@router.get('/referrers', response_model=PartnerReferrerListResponse)
async def list_referrers(
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
search: str | None = Query(default=None),
) -> PartnerReferrerListResponse:
referral_alias = aliased(User)
has_referrals = select(referral_alias.id).where(referral_alias.referred_by_id == User.id).exists()
base_query = (
select(User).options(selectinload(User.referrer)).where(or_(User.referral_code.isnot(None), has_referrals))
)
if search:
base_query = _apply_search_filter(base_query, search)
total_query = base_query.with_only_columns(func.count()).order_by(None)
total = await db.scalar(total_query) or 0
result = await db.execute(base_query.order_by(User.created_at.desc()).offset(offset).limit(limit))
referrers = result.scalars().unique().all()
items: list[PartnerReferrerItem] = []
for referrer in referrers:
stats = await get_user_referral_stats(db, referrer.id)
items.append(_serialize_referrer(referrer, stats))
return PartnerReferrerListResponse(
items=items,
total=int(total),
limit=limit,
offset=offset,
)
@router.get('/referrers/{user_id}', response_model=PartnerReferrerDetail)
async def get_referrer_detail(
user_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> PartnerReferrerDetail:
user = await get_user_by_telegram_id(db, user_id)
if not user:
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
stats = await get_user_referral_stats(db, user.id)
referrer_item = _serialize_referrer(user, stats)
referrals_data = await get_detailed_referral_list(db, user.id, limit=limit, offset=offset)
referral_items = [_serialize_referral_item(referral) for referral in referrals_data.get('referrals', [])]
referrals_list = PartnerReferralList(
items=referral_items,
total=int(referrals_data.get('total_count') or 0),
limit=limit,
offset=offset,
has_next=bool(referrals_data.get('has_next')),
has_prev=bool(referrals_data.get('has_prev')),
current_page=int(referrals_data.get('current_page') or 1),
total_pages=int(referrals_data.get('total_pages') or 1),
)
return PartnerReferrerDetail(referrer=referrer_item, referrals=referrals_list)
@router.patch('/referrers/{user_id}/commission', response_model=PartnerReferrerItem)
async def update_referrer_commission(
user_id: int,
payload: PartnerReferralCommissionUpdate,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PartnerReferrerItem:
user = await get_user_by_telegram_id(db, user_id)
if not user:
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
await update_user(
db,
user,
referral_commission_percent=payload.referral_commission_percent,
)
stats = await get_user_referral_stats(db, user.id)
return _serialize_referrer(user, stats)
# ============================================================================
# РАСШИРЕННАЯ СТАТИСТИКА ПАРТНЁРОВ
# ============================================================================
@router.get('/stats', response_model=GlobalPartnerStats)
async def get_global_partner_stats(
days: int = Query(30, ge=1, le=365),
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> GlobalPartnerStats:
"""Глобальная статистика партнёрской программы."""
data = await PartnerStatsService.get_global_partner_stats(db, days)
return GlobalPartnerStats(
summary=GlobalPartnerSummary(**data['summary']),
payouts=PayoutsByPeriod(**data['payouts']),
new_referrals=NewReferralsByPeriod(**data['new_referrals']),
)
@router.get('/stats/daily', response_model=DailyStatsResponse)
async def get_global_daily_stats(
days: int = Query(30, ge=1, le=365),
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> DailyStatsResponse:
"""Глобальная статистика по дням."""
data = await PartnerStatsService.get_global_daily_stats(db, days)
return DailyStatsResponse(
items=[DailyStats(**item) for item in data],
days=days,
user_id=None,
)
@router.get('/stats/top-referrers', response_model=TopReferrersResponse)
async def get_top_referrers(
limit: int = Query(10, ge=1, le=100),
days: int | None = Query(None, ge=1, le=365),
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> TopReferrersResponse:
"""Топ рефереров по заработку."""
data = await PartnerStatsService.get_top_referrers(db, limit, days)
return TopReferrersResponse(
items=[TopReferrerItem(**item) for item in data],
days=days,
)
@router.get('/referrers/{user_id}/stats', response_model=ReferrerDetailedStats)
async def get_referrer_detailed_stats(
user_id: int,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> ReferrerDetailedStats:
"""Детальная статистика реферера."""
user = await get_user_by_telegram_id(db, user_id)
if not user:
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
data = await PartnerStatsService.get_referrer_detailed_stats(db, user.id)
return ReferrerDetailedStats(
user_id=data['user_id'],
summary=ReferrerSummary(**data['summary']),
earnings=EarningsByPeriod(**data['earnings']),
referrals_count=ReferralsCountByPeriod(**data['referrals_count']),
)
@router.get('/referrers/{user_id}/stats/daily', response_model=DailyStatsResponse)
async def get_referrer_daily_stats(
user_id: int,
days: int = Query(30, ge=1, le=365),
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> DailyStatsResponse:
"""Статистика реферера по дням."""
user = await get_user_by_telegram_id(db, user_id)
if not user:
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
data = await PartnerStatsService.get_referrer_daily_stats(db, user.id, days)
return DailyStatsResponse(
items=[DailyStats(**item) for item in data],
days=days,
user_id=user.id,
)
@router.get('/referrers/{user_id}/stats/top-referrals', response_model=TopReferralsResponse)
async def get_referrer_top_referrals(
user_id: int,
limit: int = Query(10, ge=1, le=100),
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> TopReferralsResponse:
"""Топ рефералов реферера по принесённому доходу."""
user = await get_user_by_telegram_id(db, user_id)
if not user:
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
data = await PartnerStatsService.get_referrer_top_referrals(db, user.id, limit)
return TopReferralsResponse(
items=[TopReferralItem(**item) for item in data],
user_id=user.id,
)
@router.get('/referrers/{user_id}/stats/compare', response_model=PeriodComparisonResponse)
async def get_referrer_period_comparison(
user_id: int,
current_days: int = Query(7, ge=1, le=365),
previous_days: int = Query(7, ge=1, le=365),
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> PeriodComparisonResponse:
"""Сравнение периодов для реферера."""
user = await get_user_by_telegram_id(db, user_id)
if not user:
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
data = await PartnerStatsService.get_referrer_period_comparison(db, user.id, current_days, previous_days)
return PeriodComparisonResponse(
current_period=PeriodData(**data['current_period']),
previous_period=PeriodData(**data['previous_period']),
change=PeriodChange(
referrals=ChangeData(**data['change']['referrals']),
earnings=ChangeData(**data['change']['earnings']),
),
user_id=user.id,
)