mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-28 07:11:37 +00:00
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:
@@ -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)
|
||||
|
||||
393
app/cabinet/routes/admin_partners.py
Normal file
393
app/cabinet/routes/admin_partners.py
Normal 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}
|
||||
243
app/cabinet/routes/admin_withdrawals.py
Normal file
243
app/cabinet/routes/admin_withdrawals.py
Normal 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}
|
||||
94
app/cabinet/routes/partner_application.py
Normal file
94
app/cabinet/routes/partner_application.py
Normal 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,
|
||||
)
|
||||
146
app/cabinet/routes/withdrawal.py
Normal file
146
app/cabinet/routes/withdrawal.py
Normal 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}
|
||||
147
app/cabinet/schemas/partners.py
Normal file
147
app/cabinet/schemas/partners.py
Normal 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)
|
||||
128
app/cabinet/schemas/withdrawals.py
Normal file
128
app/cabinet/schemas/withdrawals.py
Normal 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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
227
app/services/partner_application_service.py
Normal file
227
app/services/partner_application_service.py
Normal 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()
|
||||
Reference in New Issue
Block a user