diff --git a/app/cabinet/routes/__init__.py b/app/cabinet/routes/__init__.py index 4a1f142b..a0d51c4d 100644 --- a/app/cabinet/routes/__init__.py +++ b/app/cabinet/routes/__init__.py @@ -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) diff --git a/app/cabinet/routes/admin_partners.py b/app/cabinet/routes/admin_partners.py new file mode 100644 index 00000000..bd466957 --- /dev/null +++ b/app/cabinet/routes/admin_partners.py @@ -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} diff --git a/app/cabinet/routes/admin_withdrawals.py b/app/cabinet/routes/admin_withdrawals.py new file mode 100644 index 00000000..58f73481 --- /dev/null +++ b/app/cabinet/routes/admin_withdrawals.py @@ -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} diff --git a/app/cabinet/routes/partner_application.py b/app/cabinet/routes/partner_application.py new file mode 100644 index 00000000..58c6854c --- /dev/null +++ b/app/cabinet/routes/partner_application.py @@ -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, + ) diff --git a/app/cabinet/routes/withdrawal.py b/app/cabinet/routes/withdrawal.py new file mode 100644 index 00000000..46c5ced2 --- /dev/null +++ b/app/cabinet/routes/withdrawal.py @@ -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} diff --git a/app/cabinet/schemas/partners.py b/app/cabinet/schemas/partners.py new file mode 100644 index 00000000..e5a08d1c --- /dev/null +++ b/app/cabinet/schemas/partners.py @@ -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) diff --git a/app/cabinet/schemas/withdrawals.py b/app/cabinet/schemas/withdrawals.py new file mode 100644 index 00000000..8500d721 --- /dev/null +++ b/app/cabinet/schemas/withdrawals.py @@ -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) diff --git a/app/database/models.py b/app/database/models.py index 67dfa144..0b325ce0 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -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: diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index a937120f..2c26c4f2 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -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(): diff --git a/app/services/partner_application_service.py b/app/services/partner_application_service.py new file mode 100644 index 00000000..e3bd7103 --- /dev/null +++ b/app/services/partner_application_service.py @@ -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()