feat: add partner system and withdrawal management to cabinet

- Partner application flow: user applies, admin reviews/approves/rejects
- Individual commission % per partner with admin management
- Campaign assignment/unassignment to partners
- Withdrawal system: balance check, create request, cancel
- Admin withdrawal management with risk scoring and fraud analysis
- Database migration: partner_applications table, user partner fields, campaign partner_user_id
- Pydantic schemas with proper validation bounds
- Batch user fetching to prevent N+1 queries
- Row locking on cancel to prevent race conditions
This commit is contained in:
Fringg
2026-02-17 09:51:36 +03:00
parent df5415f30b
commit 58bfaeaddb
10 changed files with 1629 additions and 0 deletions

View File

@@ -8,6 +8,7 @@ from .admin_broadcasts import router as admin_broadcasts_router
from .admin_button_styles import router as admin_button_styles_router
from .admin_campaigns import router as admin_campaigns_router
from .admin_email_templates import router as admin_email_templates_router
from .admin_partners import router as admin_partners_router
from .admin_payment_methods import router as admin_payment_methods_router
from .admin_payments import router as admin_payments_router
from .admin_pinned_messages import router as admin_pinned_messages_router
@@ -23,6 +24,7 @@ from .admin_traffic import router as admin_traffic_router
from .admin_updates import router as admin_updates_router
from .admin_users import router as admin_users_router
from .admin_wheel import router as admin_wheel_router
from .admin_withdrawals import router as admin_withdrawals_router
from .auth import router as auth_router
from .balance import router as balance_router
from .branding import router as branding_router
@@ -31,6 +33,7 @@ from .info import router as info_router
from .media import router as media_router
from .notifications import router as notifications_router
from .oauth import router as oauth_router
from .partner_application import router as partner_application_router
from .polls import router as polls_router
from .promo import router as promo_router
from .promocode import router as promocode_router
@@ -43,6 +46,7 @@ from .ticket_notifications import (
from .tickets import router as tickets_router
from .websocket import router as websocket_router
from .wheel import router as wheel_router
from .withdrawal import router as withdrawal_router
# Main cabinet router
@@ -54,6 +58,8 @@ router.include_router(oauth_router)
router.include_router(subscription_router)
router.include_router(balance_router)
router.include_router(referral_router)
router.include_router(partner_application_router)
router.include_router(withdrawal_router)
# Notifications router MUST be before tickets router to avoid route conflict
router.include_router(ticket_notifications_router)
router.include_router(tickets_router)
@@ -83,6 +89,8 @@ router.include_router(admin_broadcasts_router)
router.include_router(admin_promocodes_router)
router.include_router(admin_promo_groups_router)
router.include_router(admin_campaigns_router)
router.include_router(admin_partners_router)
router.include_router(admin_withdrawals_router)
router.include_router(admin_users_router)
router.include_router(admin_payment_methods_router)
router.include_router(admin_payments_router)

View File

@@ -0,0 +1,393 @@
"""Admin routes for managing partners in cabinet."""
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import (
AdvertisingCampaign,
PartnerApplication,
PartnerStatus,
ReferralEarning,
User,
)
from app.services.partner_application_service import partner_application_service
from app.services.partner_stats_service import PartnerStatsService
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.partners import (
AdminApproveRequest,
AdminPartnerApplicationItem,
AdminPartnerApplicationsResponse,
AdminPartnerDetailResponse,
AdminPartnerItem,
AdminPartnerListResponse,
AdminRejectRequest,
AdminUpdateCommissionRequest,
CampaignSummary,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/partners', tags=['Cabinet Admin Partners'])
# ==================== Applications (static paths first) ====================
@router.get('/applications', response_model=AdminPartnerApplicationsResponse)
async def list_applications(
application_status: str | None = Query(None, alias='status'),
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List partner applications."""
applications, total = await partner_application_service.get_all_applications(
db, status=application_status, limit=limit, offset=offset
)
# Batch-fetch users to avoid N+1
user_ids = list({app.user_id for app in applications})
if user_ids:
users_result = await db.execute(select(User).where(User.id.in_(user_ids)))
users_map = {u.id: u for u in users_result.scalars().all()}
else:
users_map = {}
items = []
for app in applications:
user = users_map.get(app.user_id)
items.append(
AdminPartnerApplicationItem(
id=app.id,
user_id=app.user_id,
username=user.username if user else None,
first_name=user.first_name if user else None,
telegram_id=user.telegram_id if user else None,
company_name=app.company_name,
website_url=app.website_url,
telegram_channel=app.telegram_channel,
description=app.description,
expected_monthly_referrals=app.expected_monthly_referrals,
status=app.status,
admin_comment=app.admin_comment,
approved_commission_percent=app.approved_commission_percent,
created_at=app.created_at,
processed_at=app.processed_at,
)
)
return AdminPartnerApplicationsResponse(items=items, total=total)
@router.post('/applications/{application_id}/approve')
async def approve_application(
application_id: int,
request: AdminApproveRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Approve a partner application."""
success, error = await partner_application_service.approve_application(
db,
application_id=application_id,
admin_id=admin.id,
commission_percent=request.commission_percent,
comment=request.comment,
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
return {'success': True}
@router.post('/applications/{application_id}/reject')
async def reject_application(
application_id: int,
request: AdminRejectRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reject a partner application."""
success, error = await partner_application_service.reject_application(
db,
application_id=application_id,
admin_id=admin.id,
comment=request.comment,
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
return {'success': True}
# ==================== Stats (static paths) ====================
@router.get('/stats')
async def get_partner_stats(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get overall partner statistics."""
total_partners = await db.execute(
select(func.count()).select_from(User).where(User.partner_status == PartnerStatus.APPROVED.value)
)
pending_apps = await db.execute(
select(func.count())
.select_from(PartnerApplication)
.where(PartnerApplication.status == PartnerStatus.PENDING.value)
)
total_referrals = await db.execute(select(func.count()).select_from(User).where(User.referred_by_id.isnot(None)))
total_earnings = await db.execute(select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)))
return {
'total_partners': total_partners.scalar() or 0,
'pending_applications': pending_apps.scalar() or 0,
'total_referrals': total_referrals.scalar() or 0,
'total_earnings_kopeks': total_earnings.scalar() or 0,
}
# ==================== Partners list ====================
@router.get('', response_model=AdminPartnerListResponse)
async def list_partners(
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List approved partners."""
count_result = await db.execute(
select(func.count()).select_from(User).where(User.partner_status == PartnerStatus.APPROVED.value)
)
total = count_result.scalar() or 0
result = await db.execute(
select(User)
.where(User.partner_status == PartnerStatus.APPROVED.value)
.order_by(desc(User.created_at))
.offset(offset)
.limit(limit)
)
partners = result.scalars().all()
# Batch-fetch earnings and referral counts to avoid N+1
partner_ids = [u.id for u in partners]
earnings_map: dict[int, int] = {}
referral_count_map: dict[int, int] = {}
if partner_ids:
earnings_result = await db.execute(
select(ReferralEarning.user_id, func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
.where(ReferralEarning.user_id.in_(partner_ids))
.group_by(ReferralEarning.user_id)
)
earnings_map = {row[0]: int(row[1]) for row in earnings_result.all()}
referral_result = await db.execute(
select(User.referred_by_id, func.count())
.where(User.referred_by_id.in_(partner_ids))
.group_by(User.referred_by_id)
)
referral_count_map = {row[0]: row[1] for row in referral_result.all()}
items = []
for user in partners:
items.append(
AdminPartnerItem(
user_id=user.id,
username=user.username,
first_name=user.first_name,
telegram_id=user.telegram_id,
commission_percent=user.referral_commission_percent,
total_referrals=referral_count_map.get(user.id, 0),
total_earnings_kopeks=earnings_map.get(user.id, 0),
balance_kopeks=user.balance_kopeks,
partner_status=user.partner_status,
created_at=user.created_at,
)
)
return AdminPartnerListResponse(items=items, total=total)
# ==================== Partner detail (parametric paths last) ====================
@router.get('/{user_id}', response_model=AdminPartnerDetailResponse)
async def get_partner_detail(
user_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed partner info."""
user = await db.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Пользователь не найден',
)
stats = await PartnerStatsService.get_referrer_detailed_stats(db, user_id)
# Get assigned campaigns
campaigns_result = await db.execute(
select(AdvertisingCampaign).where(AdvertisingCampaign.partner_user_id == user_id)
)
campaigns = campaigns_result.scalars().all()
campaign_list = [
CampaignSummary(
id=c.id,
name=c.name,
start_parameter=c.start_parameter,
is_active=c.is_active,
)
for c in campaigns
]
summary = stats['summary']
earnings = stats['earnings']
return AdminPartnerDetailResponse(
user_id=user.id,
username=user.username,
first_name=user.first_name,
telegram_id=user.telegram_id,
commission_percent=user.referral_commission_percent,
partner_status=user.partner_status,
balance_kopeks=user.balance_kopeks,
total_referrals=summary['total_referrals'],
paid_referrals=summary['paid_referrals'],
active_referrals=summary['active_referrals'],
earnings_all_time=earnings['all_time_kopeks'],
earnings_today=earnings['today_kopeks'],
earnings_week=earnings['week_kopeks'],
earnings_month=earnings['month_kopeks'],
conversion_to_paid=summary['conversion_to_paid_percent'],
campaigns=campaign_list,
created_at=user.created_at,
)
@router.patch('/{user_id}/commission')
async def update_commission(
user_id: int,
request: AdminUpdateCommissionRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update partner commission percent."""
user = await db.get(User, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Пользователь не найден',
)
if user.partner_status != PartnerStatus.APPROVED.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Пользователь не является партнёром',
)
old_commission = user.referral_commission_percent
user.referral_commission_percent = request.commission_percent
await db.commit()
logger.info(
'Комиссия партнёра обновлена',
user_id=user_id,
old_commission=old_commission,
new_commission=request.commission_percent,
admin_id=admin.id,
)
return {'success': True, 'commission_percent': request.commission_percent}
@router.post('/{user_id}/revoke')
async def revoke_partner(
user_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Revoke partner status."""
success, error = await partner_application_service.revoke_partner(db, user_id=user_id, admin_id=admin.id)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
return {'success': True}
@router.post('/{user_id}/campaigns/{campaign_id}/assign')
async def assign_campaign(
user_id: int,
campaign_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Assign a campaign to a partner."""
campaign = await db.get(AdvertisingCampaign, campaign_id)
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Кампания не найдена',
)
user = await db.get(User, user_id)
if not user or user.partner_status != PartnerStatus.APPROVED.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Пользователь не является партнёром',
)
campaign.partner_user_id = user_id
await db.commit()
return {'success': True}
@router.post('/{user_id}/campaigns/{campaign_id}/unassign')
async def unassign_campaign(
user_id: int,
campaign_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Unassign a campaign from a partner."""
campaign = await db.get(AdvertisingCampaign, campaign_id)
if not campaign:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Кампания не найдена',
)
if campaign.partner_user_id != user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Кампания не привязана к этому партнёру',
)
campaign.partner_user_id = None
await db.commit()
return {'success': True}

View File

@@ -0,0 +1,243 @@
"""Admin routes for managing withdrawal requests in cabinet."""
import json
import structlog
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import (
ReferralEarning,
User,
WithdrawalRequest,
WithdrawalRequestStatus,
)
from app.services.referral_withdrawal_service import referral_withdrawal_service
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.withdrawals import (
AdminApproveWithdrawalRequest,
AdminRejectWithdrawalRequest,
AdminWithdrawalDetailResponse,
AdminWithdrawalItem,
AdminWithdrawalListResponse,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/withdrawals', tags=['Cabinet Admin Withdrawals'])
def _get_risk_level(risk_score: int) -> str:
"""Get risk level from score."""
if risk_score >= 70:
return 'critical'
if risk_score >= 50:
return 'high'
if risk_score >= 30:
return 'medium'
return 'low'
@router.get('', response_model=AdminWithdrawalListResponse)
async def list_withdrawals(
withdrawal_status: str | None = Query(None, alias='status'),
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""List all withdrawal requests."""
query = select(WithdrawalRequest)
count_query = select(func.count()).select_from(WithdrawalRequest)
if withdrawal_status:
query = query.where(WithdrawalRequest.status == withdrawal_status)
count_query = count_query.where(WithdrawalRequest.status == withdrawal_status)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Pending stats
pending_count_result = await db.execute(
select(func.count())
.select_from(WithdrawalRequest)
.where(WithdrawalRequest.status == WithdrawalRequestStatus.PENDING.value)
)
pending_count = pending_count_result.scalar() or 0
pending_total_result = await db.execute(
select(func.coalesce(func.sum(WithdrawalRequest.amount_kopeks), 0)).where(
WithdrawalRequest.status == WithdrawalRequestStatus.PENDING.value
)
)
pending_total = pending_total_result.scalar() or 0
query = query.order_by(desc(WithdrawalRequest.created_at)).offset(offset).limit(limit)
result = await db.execute(query)
withdrawals = result.scalars().all()
# Batch-fetch users to avoid N+1
user_ids = list({w.user_id for w in withdrawals})
if user_ids:
users_result = await db.execute(select(User).where(User.id.in_(user_ids)))
users_map = {u.id: u for u in users_result.scalars().all()}
else:
users_map = {}
items = []
for w in withdrawals:
user = users_map.get(w.user_id)
items.append(
AdminWithdrawalItem(
id=w.id,
user_id=w.user_id,
username=user.username if user else None,
first_name=user.first_name if user else None,
telegram_id=user.telegram_id if user else None,
amount_kopeks=w.amount_kopeks,
amount_rubles=w.amount_kopeks / 100,
status=w.status,
risk_score=w.risk_score or 0,
risk_level=_get_risk_level(w.risk_score or 0),
payment_details=w.payment_details,
admin_comment=w.admin_comment,
created_at=w.created_at,
processed_at=w.processed_at,
)
)
return AdminWithdrawalListResponse(
items=items,
total=total,
pending_count=pending_count,
pending_total_kopeks=pending_total,
)
@router.get('/{withdrawal_id}', response_model=AdminWithdrawalDetailResponse)
async def get_withdrawal_detail(
withdrawal_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get detailed withdrawal request with risk analysis."""
withdrawal = await db.get(WithdrawalRequest, withdrawal_id)
if not withdrawal:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Заявка не найдена',
)
user = await db.get(User, withdrawal.user_id)
# Parse risk analysis
risk_analysis = None
if withdrawal.risk_analysis:
try:
risk_analysis = json.loads(withdrawal.risk_analysis)
except (json.JSONDecodeError, TypeError):
pass
# Get referral stats
referral_count = await db.execute(
select(func.count()).select_from(User).where(User.referred_by_id == withdrawal.user_id)
)
total_earnings = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0)).where(
ReferralEarning.user_id == withdrawal.user_id
)
)
return AdminWithdrawalDetailResponse(
id=withdrawal.id,
user_id=withdrawal.user_id,
username=user.username if user else None,
first_name=user.first_name if user else None,
telegram_id=user.telegram_id if user else None,
amount_kopeks=withdrawal.amount_kopeks,
amount_rubles=withdrawal.amount_kopeks / 100,
status=withdrawal.status,
risk_score=withdrawal.risk_score or 0,
risk_level=_get_risk_level(withdrawal.risk_score or 0),
risk_analysis=risk_analysis,
payment_details=withdrawal.payment_details,
admin_comment=withdrawal.admin_comment,
balance_kopeks=user.balance_kopeks if user else 0,
total_referrals=referral_count.scalar() or 0,
total_earnings_kopeks=total_earnings.scalar() or 0,
created_at=withdrawal.created_at,
processed_at=withdrawal.processed_at,
)
@router.post('/{withdrawal_id}/approve')
async def approve_withdrawal(
withdrawal_id: int,
request: AdminApproveWithdrawalRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Approve a withdrawal request."""
success, error = await referral_withdrawal_service.approve_request(
db,
request_id=withdrawal_id,
admin_id=admin.id,
comment=request.comment,
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
return {'success': True}
@router.post('/{withdrawal_id}/reject')
async def reject_withdrawal(
withdrawal_id: int,
request: AdminRejectWithdrawalRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reject a withdrawal request."""
success = await referral_withdrawal_service.reject_request(
db,
request_id=withdrawal_id,
admin_id=admin.id,
comment=request.comment,
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Не удалось отклонить заявку',
)
return {'success': True}
@router.post('/{withdrawal_id}/complete')
async def complete_withdrawal(
withdrawal_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Mark a withdrawal as completed (money transferred)."""
success = await referral_withdrawal_service.complete_request(
db,
request_id=withdrawal_id,
admin_id=admin.id,
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Не удалось завершить заявку',
)
return {'success': True}

View File

@@ -0,0 +1,94 @@
"""User-facing partner application routes for cabinet."""
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.services.partner_application_service import partner_application_service
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.partners import (
PartnerApplicationInfo,
PartnerApplicationRequest,
PartnerStatusResponse,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/referral/partner', tags=['Cabinet Partner'])
@router.get('/status', response_model=PartnerStatusResponse)
async def get_partner_status(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get partner status and latest application for current user."""
latest_app = await partner_application_service.get_latest_application(db, user.id)
app_info = None
if latest_app:
app_info = PartnerApplicationInfo(
id=latest_app.id,
status=latest_app.status,
company_name=latest_app.company_name,
website_url=latest_app.website_url,
telegram_channel=latest_app.telegram_channel,
description=latest_app.description,
expected_monthly_referrals=latest_app.expected_monthly_referrals,
admin_comment=latest_app.admin_comment,
approved_commission_percent=latest_app.approved_commission_percent,
created_at=latest_app.created_at,
processed_at=latest_app.processed_at,
)
commission = user.referral_commission_percent
if commission is None and user.is_partner:
commission = settings.REFERRAL_COMMISSION_PERCENT
return PartnerStatusResponse(
partner_status=user.partner_status,
commission_percent=commission,
latest_application=app_info,
)
@router.post('/apply', response_model=PartnerApplicationInfo)
async def apply_for_partner(
request: PartnerApplicationRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Submit partner application."""
application, error = await partner_application_service.submit_application(
db,
user_id=user.id,
company_name=request.company_name,
website_url=request.website_url,
telegram_channel=request.telegram_channel,
description=request.description,
expected_monthly_referrals=request.expected_monthly_referrals,
)
if not application:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
return PartnerApplicationInfo(
id=application.id,
status=application.status,
company_name=application.company_name,
website_url=application.website_url,
telegram_channel=application.telegram_channel,
description=application.description,
expected_monthly_referrals=application.expected_monthly_referrals,
admin_comment=application.admin_comment,
approved_commission_percent=application.approved_commission_percent,
created_at=application.created_at,
processed_at=application.processed_at,
)

View File

@@ -0,0 +1,146 @@
"""User-facing withdrawal routes for cabinet."""
import structlog
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User, WithdrawalRequest, WithdrawalRequestStatus
from app.services.referral_withdrawal_service import referral_withdrawal_service
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.withdrawals import (
WithdrawalBalanceResponse,
WithdrawalCreateRequest,
WithdrawalCreateResponse,
WithdrawalItemResponse,
WithdrawalListResponse,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/referral/withdrawal', tags=['Cabinet Withdrawal'])
@router.get('/balance', response_model=WithdrawalBalanceResponse)
async def get_withdrawal_balance(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get withdrawal balance stats for current user."""
stats = await referral_withdrawal_service.get_referral_balance_stats(db, user.id)
can_request, reason = await referral_withdrawal_service.can_request_withdrawal(db, user.id)
return WithdrawalBalanceResponse(
total_earned=stats['total_earned'],
referral_spent=stats['referral_spent'],
withdrawn=stats['withdrawn'],
pending=stats['pending'],
available_referral=stats['available_referral'],
available_total=stats['available_total'],
only_referral_mode=stats['only_referral_mode'],
min_amount_kopeks=settings.REFERRAL_WITHDRAWAL_MIN_AMOUNT_KOPEKS,
is_withdrawal_enabled=settings.is_referral_withdrawal_enabled(),
can_request=can_request,
cannot_request_reason=reason if not can_request else None,
)
@router.post('/create', response_model=WithdrawalCreateResponse)
async def create_withdrawal(
request: WithdrawalCreateRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Create a withdrawal request."""
withdrawal, error = await referral_withdrawal_service.create_withdrawal_request(
db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
payment_details=request.payment_details,
)
if not withdrawal:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error,
)
return WithdrawalCreateResponse(
id=withdrawal.id,
amount_kopeks=withdrawal.amount_kopeks,
status=withdrawal.status,
)
@router.get('/history', response_model=WithdrawalListResponse)
async def get_withdrawal_history(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user's withdrawal request history."""
count_result = await db.execute(
select(func.count()).select_from(WithdrawalRequest).where(WithdrawalRequest.user_id == user.id)
)
total = count_result.scalar() or 0
result = await db.execute(
select(WithdrawalRequest)
.where(WithdrawalRequest.user_id == user.id)
.order_by(desc(WithdrawalRequest.created_at))
.limit(50)
)
requests = result.scalars().all()
items = [
WithdrawalItemResponse(
id=r.id,
amount_kopeks=r.amount_kopeks,
amount_rubles=r.amount_kopeks / 100,
status=r.status,
payment_details=r.payment_details,
admin_comment=r.admin_comment,
created_at=r.created_at,
processed_at=r.processed_at,
)
for r in requests
]
return WithdrawalListResponse(items=items, total=total)
@router.post('/{request_id}/cancel')
async def cancel_withdrawal(
request_id: int,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Cancel a pending withdrawal request."""
result = await db.execute(
select(WithdrawalRequest)
.where(
WithdrawalRequest.id == request_id,
WithdrawalRequest.user_id == user.id,
)
.with_for_update()
)
withdrawal = result.scalar_one_or_none()
if not withdrawal:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='Заявка не найдена',
)
if withdrawal.status != WithdrawalRequestStatus.PENDING.value:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Можно отменить только заявку в ожидании',
)
withdrawal.status = WithdrawalRequestStatus.CANCELLED.value
await db.commit()
return {'success': True}

View File

@@ -0,0 +1,147 @@
"""Partner system schemas for cabinet."""
from datetime import datetime
from pydantic import BaseModel, Field
# ==================== User-facing ====================
class PartnerApplicationRequest(BaseModel):
"""Request to apply for partner status."""
company_name: str | None = Field(None, max_length=255)
website_url: str | None = Field(None, max_length=500)
telegram_channel: str | None = Field(None, max_length=255)
description: str | None = Field(None, max_length=2000)
expected_monthly_referrals: int | None = Field(None, ge=0)
class PartnerApplicationInfo(BaseModel):
"""Application info for the user."""
id: int
status: str
company_name: str | None = None
website_url: str | None = None
telegram_channel: str | None = None
description: str | None = None
expected_monthly_referrals: int | None = None
admin_comment: str | None = None
approved_commission_percent: int | None = None
created_at: datetime
processed_at: datetime | None = None
class Config:
from_attributes = True
class PartnerStatusResponse(BaseModel):
"""Partner status for current user."""
partner_status: str
commission_percent: int | None = None
latest_application: PartnerApplicationInfo | None = None
# ==================== Admin-facing ====================
class AdminPartnerApplicationItem(BaseModel):
"""Partner application in admin list."""
id: int
user_id: int
username: str | None = None
first_name: str | None = None
telegram_id: int | None = None
company_name: str | None = None
website_url: str | None = None
telegram_channel: str | None = None
description: str | None = None
expected_monthly_referrals: int | None = None
status: str
admin_comment: str | None = None
approved_commission_percent: int | None = None
created_at: datetime
processed_at: datetime | None = None
class AdminPartnerApplicationsResponse(BaseModel):
"""List of partner applications."""
items: list[AdminPartnerApplicationItem]
total: int
class AdminApproveRequest(BaseModel):
"""Request to approve a partner application."""
commission_percent: int = Field(..., ge=1, le=100)
comment: str | None = Field(None, max_length=2000)
class AdminRejectRequest(BaseModel):
"""Request to reject a partner application."""
comment: str | None = Field(None, max_length=2000)
class AdminPartnerItem(BaseModel):
"""Partner in admin list."""
user_id: int
username: str | None = None
first_name: str | None = None
telegram_id: int | None = None
commission_percent: int | None = None
total_referrals: int = 0
total_earnings_kopeks: int = 0
balance_kopeks: int = 0
partner_status: str
created_at: datetime
class AdminPartnerListResponse(BaseModel):
"""List of partners for admin."""
items: list[AdminPartnerItem]
total: int
class CampaignSummary(BaseModel):
"""Campaign summary for partner detail."""
id: int
name: str
start_parameter: str
is_active: bool
class AdminPartnerDetailResponse(BaseModel):
"""Detailed partner info for admin."""
user_id: int
username: str | None = None
first_name: str | None = None
telegram_id: int | None = None
commission_percent: int | None = None
partner_status: str
balance_kopeks: int = 0
total_referrals: int = 0
paid_referrals: int = 0
active_referrals: int = 0
earnings_all_time: int = 0
earnings_today: int = 0
earnings_week: int = 0
earnings_month: int = 0
conversion_to_paid: float = 0.0
campaigns: list[CampaignSummary] = []
created_at: datetime
class AdminUpdateCommissionRequest(BaseModel):
"""Request to update partner commission."""
commission_percent: int = Field(..., ge=1, le=100)

View File

@@ -0,0 +1,128 @@
"""Withdrawal system schemas for cabinet."""
from datetime import datetime
from pydantic import BaseModel, Field
# ==================== User-facing ====================
class WithdrawalBalanceResponse(BaseModel):
"""Withdrawal balance info for user."""
total_earned: int
referral_spent: int
withdrawn: int
pending: int
available_referral: int
available_total: int
only_referral_mode: bool
min_amount_kopeks: int
is_withdrawal_enabled: bool
can_request: bool
cannot_request_reason: str | None = None
class WithdrawalCreateRequest(BaseModel):
"""Request to create a withdrawal."""
amount_kopeks: int = Field(..., gt=0, le=10_000_000)
payment_details: str = Field(..., min_length=5, max_length=1000)
class WithdrawalItemResponse(BaseModel):
"""Withdrawal request item."""
id: int
amount_kopeks: int
amount_rubles: float
status: str
payment_details: str | None = None
admin_comment: str | None = None
created_at: datetime
processed_at: datetime | None = None
class Config:
from_attributes = True
class WithdrawalListResponse(BaseModel):
"""List of user's withdrawal requests."""
items: list[WithdrawalItemResponse]
total: int
class WithdrawalCreateResponse(BaseModel):
"""Response after creating withdrawal."""
id: int
amount_kopeks: int
status: str
# ==================== Admin-facing ====================
class AdminWithdrawalItem(BaseModel):
"""Withdrawal request in admin list."""
id: int
user_id: int
username: str | None = None
first_name: str | None = None
telegram_id: int | None = None
amount_kopeks: int
amount_rubles: float
status: str
risk_score: int = 0
risk_level: str = 'low'
payment_details: str | None = None
admin_comment: str | None = None
created_at: datetime
processed_at: datetime | None = None
class AdminWithdrawalListResponse(BaseModel):
"""List of withdrawal requests for admin."""
items: list[AdminWithdrawalItem]
total: int
pending_count: int = 0
pending_total_kopeks: int = 0
class AdminWithdrawalDetailResponse(BaseModel):
"""Detailed withdrawal request for admin."""
id: int
user_id: int
username: str | None = None
first_name: str | None = None
telegram_id: int | None = None
amount_kopeks: int
amount_rubles: float
status: str
risk_score: int = 0
risk_level: str = 'low'
risk_analysis: dict | None = None
payment_details: str | None = None
admin_comment: str | None = None
balance_kopeks: int = 0
total_referrals: int = 0
total_earnings_kopeks: int = 0
created_at: datetime
processed_at: datetime | None = None
class AdminApproveWithdrawalRequest(BaseModel):
"""Request to approve a withdrawal."""
comment: str | None = Field(None, max_length=2000)
class AdminRejectWithdrawalRequest(BaseModel):
"""Request to reject a withdrawal."""
comment: str | None = Field(None, max_length=2000)

View File

@@ -1031,6 +1031,14 @@ class User(Base):
restriction_subscription = Column(Boolean, default=False, nullable=False) # Запрет продления/покупки
restriction_reason = Column(String(500), nullable=True) # Причина ограничения
# Партнёрская система
partner_status = Column(String(20), default=PartnerStatus.NONE.value, nullable=False)
@property
def is_partner(self) -> bool:
"""Проверить, является ли пользователь одобренным партнёром."""
return self.partner_status == PartnerStatus.APPROVED.value
@property
def has_restrictions(self) -> bool:
"""Проверить, есть ли у пользователя активные ограничения."""
@@ -1471,6 +1479,15 @@ class ReferralEarning(Base):
return self.amount_kopeks / 100
class PartnerStatus(Enum):
"""Статусы партнёрского аккаунта."""
NONE = 'none' # Не подавал заявку
PENDING = 'pending' # Заявка на рассмотрении
APPROVED = 'approved' # Партнёр одобрен
REJECTED = 'rejected' # Заявка отклонена
class WithdrawalRequestStatus(Enum):
"""Статусы заявки на вывод реферального баланса."""
@@ -1515,6 +1532,35 @@ class WithdrawalRequest(Base):
return self.amount_kopeks / 100
class PartnerApplication(Base):
"""Заявка на получение статуса партнёра."""
__tablename__ = 'partner_applications'
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
company_name = Column(String(255), nullable=True)
website_url = Column(String(500), nullable=True)
telegram_channel = Column(String(255), nullable=True)
description = Column(Text, nullable=True)
expected_monthly_referrals = Column(Integer, nullable=True)
status = Column(String(20), default=PartnerStatus.PENDING.value, nullable=False)
# Обработка админом
admin_comment = Column(Text, nullable=True)
approved_commission_percent = Column(Integer, nullable=True)
processed_by = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
processed_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), default=func.now())
updated_at = Column(DateTime(timezone=True), default=func.now(), onupdate=func.now())
user = relationship('User', foreign_keys=[user_id], backref='partner_applications')
admin = relationship('User', foreign_keys=[processed_by])
class ReferralContest(Base):
__tablename__ = 'referral_contests'
@@ -2160,12 +2206,16 @@ class AdvertisingCampaign(Base):
is_active = Column(Boolean, default=True)
# Привязка к партнёру
partner_user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
created_by = Column(Integer, ForeignKey('users.id'), nullable=True)
created_at = Column(DateTime(timezone=True), default=func.now())
updated_at = Column(DateTime(timezone=True), default=func.now(), onupdate=func.now())
registrations = relationship('AdvertisingCampaignRegistration', back_populates='campaign')
tariff = relationship('Tariff', foreign_keys=[tariff_id])
partner = relationship('User', foreign_keys=[partner_user_id])
@property
def is_balance_bonus(self) -> bool:

View File

@@ -7194,6 +7194,27 @@ async def run_universal_migration():
else:
logger.warning('⚠️ Проблемы с миграцией DateTime колонок')
logger.info('=== СОЗДАНИЕ ТАБЛИЦЫ PARTNER_APPLICATIONS ===')
partner_apps_ready = await create_partner_applications_table()
if partner_apps_ready:
logger.info('✅ Таблица partner_applications готова')
else:
logger.warning('⚠️ Проблемы с таблицей partner_applications')
logger.info('=== ДОБАВЛЕНИЕ КОЛОНКИ PARTNER_STATUS В USERS ===')
partner_status_ready = await add_user_partner_status_column()
if partner_status_ready:
logger.info('✅ Колонка partner_status в users готова')
else:
logger.warning('⚠️ Проблемы с колонкой partner_status')
logger.info('=== ДОБАВЛЕНИЕ КОЛОНКИ PARTNER_USER_ID В ADVERTISING_CAMPAIGNS ===')
campaign_partner_ready = await add_campaign_partner_user_id_column()
if campaign_partner_ready:
logger.info('✅ Колонка partner_user_id в advertising_campaigns готова')
else:
logger.warning('⚠️ Проблемы с колонкой partner_user_id')
async with engine.begin() as conn:
total_subs = await conn.execute(text('SELECT COUNT(*) FROM subscriptions'))
unique_users = await conn.execute(text('SELECT COUNT(DISTINCT user_id) FROM subscriptions'))
@@ -7244,6 +7265,166 @@ async def run_universal_migration():
return False
async def create_partner_applications_table() -> bool:
"""Создаёт таблицу для заявок на партнёрский статус."""
try:
if await check_table_exists('partner_applications'):
logger.debug('Таблица partner_applications уже существует')
return True
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
create_sql = """
CREATE TABLE partner_applications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
company_name VARCHAR(255),
website_url VARCHAR(500),
telegram_channel VARCHAR(255),
description TEXT,
expected_monthly_referrals INTEGER,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
admin_comment TEXT,
approved_commission_percent INTEGER,
processed_by INTEGER,
processed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (processed_by) REFERENCES users(id) ON DELETE SET NULL
)
"""
elif db_type == 'postgresql':
create_sql = """
CREATE TABLE partner_applications (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
company_name VARCHAR(255),
website_url VARCHAR(500),
telegram_channel VARCHAR(255),
description TEXT,
expected_monthly_referrals INTEGER,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
admin_comment TEXT,
approved_commission_percent INTEGER,
processed_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
processed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
)
"""
else: # mysql
create_sql = """
CREATE TABLE partner_applications (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
company_name VARCHAR(255),
website_url VARCHAR(500),
telegram_channel VARCHAR(255),
description TEXT,
expected_monthly_referrals INT,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
admin_comment TEXT,
approved_commission_percent INT,
processed_by INT,
processed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (processed_by) REFERENCES users(id) ON DELETE SET NULL
)
"""
await conn.execute(text(create_sql))
logger.info('✅ Таблица partner_applications создана')
try:
await conn.execute(
text('CREATE INDEX idx_partner_applications_user_id ON partner_applications(user_id)')
)
await conn.execute(text('CREATE INDEX idx_partner_applications_status ON partner_applications(status)'))
except Exception:
pass
return True
except Exception as error:
logger.error('❌ Ошибка создания таблицы partner_applications', error=error)
return False
async def add_user_partner_status_column() -> bool:
"""Добавляет колонку partner_status в таблицу users."""
try:
if await check_column_exists('users', 'partner_status'):
logger.info(' Колонка partner_status в users уже существует')
return True
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type in ('sqlite', 'postgresql'):
await conn.execute(
text("ALTER TABLE users ADD COLUMN partner_status VARCHAR(20) NOT NULL DEFAULT 'none'")
)
else: # MySQL
await conn.execute(
text("ALTER TABLE users ADD COLUMN partner_status VARCHAR(20) NOT NULL DEFAULT 'none'")
)
logger.info('✅ Колонка partner_status добавлена в users')
return True
except Exception as error:
logger.error('❌ Ошибка добавления колонки partner_status', error=error)
return False
async def add_campaign_partner_user_id_column() -> bool:
"""Добавляет колонку partner_user_id в таблицу advertising_campaigns."""
try:
if await check_column_exists('advertising_campaigns', 'partner_user_id'):
logger.info(' Колонка partner_user_id в advertising_campaigns уже существует')
return True
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
await conn.execute(
text('ALTER TABLE advertising_campaigns ADD COLUMN partner_user_id INTEGER REFERENCES users(id)')
)
elif db_type == 'postgresql':
await conn.execute(
text(
'ALTER TABLE advertising_campaigns ADD COLUMN partner_user_id INTEGER '
'REFERENCES users(id) ON DELETE SET NULL'
)
)
await conn.execute(
text(
'CREATE INDEX IF NOT EXISTS idx_advertising_campaigns_partner_user_id '
'ON advertising_campaigns(partner_user_id)'
)
)
else: # MySQL
await conn.execute(text('ALTER TABLE advertising_campaigns ADD COLUMN partner_user_id INT NULL'))
await conn.execute(
text(
'ALTER TABLE advertising_campaigns ADD CONSTRAINT fk_campaigns_partner '
'FOREIGN KEY (partner_user_id) REFERENCES users(id) ON DELETE SET NULL'
)
)
logger.info('✅ Колонка partner_user_id добавлена в advertising_campaigns')
return True
except Exception as error:
logger.error('❌ Ошибка добавления колонки partner_user_id', error=error)
return False
async def check_migration_status():
logger.info('=== ПРОВЕРКА СТАТУСА МИГРАЦИЙ ===')
@@ -7312,6 +7493,9 @@ async def check_migration_status():
'users_yandex_id_column': False,
'users_discord_id_column': False,
'users_vk_id_column': False,
'partner_applications_table': False,
'users_partner_status_column': False,
'campaigns_partner_user_id_column': False,
}
status['has_made_first_topup_column'] = await check_column_exists('users', 'has_made_first_topup')
@@ -7449,6 +7633,12 @@ async def check_migration_status():
status['users_discord_id_column'] = await check_column_exists('users', 'discord_id')
status['users_vk_id_column'] = await check_column_exists('users', 'vk_id')
status['partner_applications_table'] = await check_table_exists('partner_applications')
status['users_partner_status_column'] = await check_column_exists('users', 'partner_status')
status['campaigns_partner_user_id_column'] = await check_column_exists(
'advertising_campaigns', 'partner_user_id'
)
async with engine.begin() as conn:
duplicates_check = await conn.execute(
text("""
@@ -7523,6 +7713,9 @@ async def check_migration_status():
'users_yandex_id_column': 'Колонка yandex_id в users',
'users_discord_id_column': 'Колонка discord_id в users',
'users_vk_id_column': 'Колонка vk_id в users',
'partner_applications_table': 'Таблица partner_applications',
'users_partner_status_column': 'Колонка partner_status в users',
'campaigns_partner_user_id_column': 'Колонка partner_user_id в advertising_campaigns',
}
for check_key, check_status in status.items():

View File

@@ -0,0 +1,227 @@
"""Сервис для обработки заявок на партнёрский статус."""
import secrets
import string
from datetime import UTC, datetime
import structlog
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import PartnerApplication, PartnerStatus, User
logger = structlog.get_logger(__name__)
class PartnerApplicationService:
"""Сервис управления партнёрскими заявками."""
async def submit_application(
self,
db: AsyncSession,
user_id: int,
company_name: str | None = None,
website_url: str | None = None,
telegram_channel: str | None = None,
description: str | None = None,
expected_monthly_referrals: int | None = None,
) -> tuple[PartnerApplication | None, str]:
"""
Подаёт заявку на партнёрский статус.
Возвращает (application, error_message).
"""
user = await db.get(User, user_id)
if not user:
return None, 'Пользователь не найден'
if user.partner_status == PartnerStatus.APPROVED.value:
return None, 'Вы уже являетесь партнёром'
if user.partner_status == PartnerStatus.PENDING.value:
return None, 'У вас уже есть заявка на рассмотрении'
application = PartnerApplication(
user_id=user_id,
company_name=company_name,
website_url=website_url,
telegram_channel=telegram_channel,
description=description,
expected_monthly_referrals=expected_monthly_referrals,
)
user.partner_status = PartnerStatus.PENDING.value
db.add(application)
await db.commit()
await db.refresh(application)
logger.info(
'📝 Подана заявка на партнёрство',
user_id=user_id,
application_id=application.id,
)
return application, ''
async def approve_application(
self,
db: AsyncSession,
application_id: int,
admin_id: int,
commission_percent: int,
comment: str | None = None,
) -> tuple[bool, str]:
"""
Одобряет заявку на партнёрство.
Возвращает (success, error_message).
"""
application = await db.get(PartnerApplication, application_id)
if not application:
return False, 'Заявка не найдена'
if application.status != PartnerStatus.PENDING.value:
return False, 'Заявка уже обработана'
user = await db.get(User, application.user_id)
if not user:
return False, 'Пользователь не найден'
# Генерируем реферальный код, если его нет
if not user.referral_code:
user.referral_code = self._generate_referral_code()
user.partner_status = PartnerStatus.APPROVED.value
user.referral_commission_percent = commission_percent
application.status = PartnerStatus.APPROVED.value
application.approved_commission_percent = commission_percent
application.admin_comment = comment
application.processed_by = admin_id
application.processed_at = datetime.now(UTC)
await db.commit()
logger.info(
'✅ Партнёрская заявка одобрена',
application_id=application_id,
user_id=application.user_id,
commission_percent=commission_percent,
admin_id=admin_id,
)
return True, ''
async def reject_application(
self,
db: AsyncSession,
application_id: int,
admin_id: int,
comment: str | None = None,
) -> tuple[bool, str]:
"""Отклоняет заявку на партнёрство."""
application = await db.get(PartnerApplication, application_id)
if not application:
return False, 'Заявка не найдена'
if application.status != PartnerStatus.PENDING.value:
return False, 'Заявка уже обработана'
user = await db.get(User, application.user_id)
if user:
user.partner_status = PartnerStatus.REJECTED.value
application.status = PartnerStatus.REJECTED.value
application.admin_comment = comment
application.processed_by = admin_id
application.processed_at = datetime.now(UTC)
await db.commit()
logger.info(
'❌ Партнёрская заявка отклонена',
application_id=application_id,
user_id=application.user_id,
admin_id=admin_id,
)
return True, ''
async def revoke_partner(
self,
db: AsyncSession,
user_id: int,
admin_id: int,
) -> tuple[bool, str]:
"""Отзывает партнёрский статус."""
user = await db.get(User, user_id)
if not user:
return False, 'Пользователь не найден'
if user.partner_status != PartnerStatus.APPROVED.value:
return False, 'Пользователь не является партнёром'
user.partner_status = PartnerStatus.NONE.value
user.referral_commission_percent = None
await db.commit()
logger.info(
'🚫 Партнёрский статус отозван',
user_id=user_id,
admin_id=admin_id,
)
return True, ''
async def get_pending_applications(self, db: AsyncSession) -> list[PartnerApplication]:
"""Получает все заявки на рассмотрении."""
result = await db.execute(
select(PartnerApplication)
.where(PartnerApplication.status == PartnerStatus.PENDING.value)
.order_by(PartnerApplication.created_at.asc())
)
return list(result.scalars().all())
async def get_all_applications(
self,
db: AsyncSession,
status: str | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[PartnerApplication], int]:
"""Получает заявки с фильтрацией. Возвращает (items, total)."""
query = select(PartnerApplication)
count_query = select(func.count()).select_from(PartnerApplication)
if status:
query = query.where(PartnerApplication.status == status)
count_query = count_query.where(PartnerApplication.status == status)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
query = query.order_by(desc(PartnerApplication.created_at)).offset(offset).limit(limit)
result = await db.execute(query)
return list(result.scalars().all()), total
async def get_latest_application(self, db: AsyncSession, user_id: int) -> PartnerApplication | None:
"""Получает последнюю заявку пользователя."""
result = await db.execute(
select(PartnerApplication)
.where(PartnerApplication.user_id == user_id)
.order_by(desc(PartnerApplication.created_at))
.limit(1)
)
return result.scalar_one_or_none()
@staticmethod
def _generate_referral_code() -> str:
"""Генерирует уникальный реферальный код."""
chars = string.ascii_lowercase + string.digits
return ''.join(secrets.choice(chars) for _ in range(8))
# Синглтон сервиса
partner_application_service = PartnerApplicationService()