7c20fde4e8
- replace unsafe referral code generator with unique DB-checked version - remove dead code in get_global_partner_stats - validate status filter params with Literal types in admin routes
236 lines
8.4 KiB
Python
236 lines
8.4 KiB
Python
"""Сервис для обработки заявок на партнёрский статус."""
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
import structlog
|
|
from sqlalchemy import desc, func, select, update
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database.models import AdvertisingCampaign, PartnerApplication, PartnerStatus, User
|
|
from app.utils.user_utils import generate_unique_referral_code
|
|
|
|
|
|
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).
|
|
"""
|
|
result = await db.execute(
|
|
select(PartnerApplication).where(PartnerApplication.id == application_id).with_for_update()
|
|
)
|
|
application = result.scalar_one_or_none()
|
|
if not application:
|
|
return False, 'Заявка не найдена'
|
|
|
|
if application.status != PartnerStatus.PENDING.value:
|
|
return False, 'Заявка уже обработана'
|
|
|
|
user_result = await db.execute(select(User).where(User.id == application.user_id).with_for_update())
|
|
user = user_result.scalar_one_or_none()
|
|
if not user:
|
|
return False, 'Пользователь не найден'
|
|
|
|
# Генерируем реферальный код, если его нет
|
|
if not user.referral_code:
|
|
user.referral_code = await generate_unique_referral_code(db, user.telegram_id or 0)
|
|
|
|
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]:
|
|
"""Отклоняет заявку на партнёрство."""
|
|
result = await db.execute(
|
|
select(PartnerApplication).where(PartnerApplication.id == application_id).with_for_update()
|
|
)
|
|
application = result.scalar_one_or_none()
|
|
if not application:
|
|
return False, 'Заявка не найдена'
|
|
|
|
if application.status != PartnerStatus.PENDING.value:
|
|
return False, 'Заявка уже обработана'
|
|
|
|
user_result = await db.execute(select(User).where(User.id == application.user_id).with_for_update())
|
|
user = user_result.scalar_one_or_none()
|
|
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.execute(
|
|
update(AdvertisingCampaign)
|
|
.where(AdvertisingCampaign.partner_user_id == user_id)
|
|
.values(partner_user_id=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()
|
|
|
|
|
|
# Синглтон сервиса
|
|
partner_application_service = PartnerApplicationService()
|