@@ -472,6 +472,61 @@ async def delete_promocode_endpoint(
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class DeactivateDiscountResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
deactivated_code: str | None = None
|
||||
discount_percent: int = 0
|
||||
user_id: int
|
||||
|
||||
|
||||
@router.post('/deactivate-discount/{user_id}', response_model=DeactivateDiscountResponse)
|
||||
async def admin_deactivate_discount_promocode(
|
||||
user_id: int,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> DeactivateDiscountResponse:
|
||||
"""Admin: deactivate a user's active discount promo code."""
|
||||
from app.database.crud.user import get_user_by_id as get_user
|
||||
|
||||
target_user = await get_user(db, user_id)
|
||||
if not target_user:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
|
||||
|
||||
from app.services.promocode_service import PromoCodeService
|
||||
|
||||
service = PromoCodeService()
|
||||
result = await service.deactivate_discount_promocode(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
admin_initiated=True,
|
||||
)
|
||||
|
||||
if result['success']:
|
||||
return DeactivateDiscountResponse(
|
||||
success=True,
|
||||
message=f'Discount promo code deactivated for user {user_id}',
|
||||
deactivated_code=result.get('deactivated_code'),
|
||||
discount_percent=result.get('discount_percent', 0),
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
error_messages = {
|
||||
'user_not_found': 'User not found',
|
||||
'no_active_discount_promocode': 'User has no active discount from a promo code',
|
||||
'discount_already_expired': 'Discount has already expired (cleaned up)',
|
||||
'server_error': 'Server error occurred',
|
||||
}
|
||||
|
||||
error_code = result.get('error', 'server_error')
|
||||
error_message = error_messages.get(error_code, 'Failed to deactivate promo code')
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error_message,
|
||||
)
|
||||
|
||||
|
||||
# ============== PromoGroup Endpoints ==============
|
||||
|
||||
promo_groups_router = APIRouter(prefix='/admin/promo-groups', tags=['Admin Promo Groups'])
|
||||
|
||||
@@ -33,6 +33,15 @@ class PromocodeActivateResponse(BaseModel):
|
||||
bonus_description: str | None = None
|
||||
|
||||
|
||||
class PromocodeDeactivateResponse(BaseModel):
|
||||
"""Response after deactivating a discount promo code."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
deactivated_code: str | None = None
|
||||
discount_percent: int = 0
|
||||
|
||||
|
||||
@router.post('/activate', response_model=PromocodeActivateResponse)
|
||||
async def activate_promocode(
|
||||
request: PromocodeActivateRequest,
|
||||
@@ -62,6 +71,8 @@ async def activate_promocode(
|
||||
'expired': 'Promo code has expired',
|
||||
'used': 'Promo code has been fully used',
|
||||
'already_used_by_user': 'You have already used this promo code',
|
||||
'active_discount_exists': 'You already have an active discount. Deactivate it first via /deactivate-discount',
|
||||
'not_first_purchase': 'This promo code is only available for first purchase',
|
||||
'user_not_found': 'User not found',
|
||||
'server_error': 'Server error occurred',
|
||||
}
|
||||
@@ -73,3 +84,41 @@ async def activate_promocode(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error_message,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/deactivate-discount', response_model=PromocodeDeactivateResponse)
|
||||
async def deactivate_discount_promocode(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> PromocodeDeactivateResponse:
|
||||
"""Deactivate the currently active discount promo code for the current user."""
|
||||
promocode_service = PromoCodeService()
|
||||
|
||||
result = await promocode_service.deactivate_discount_promocode(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
admin_initiated=False,
|
||||
)
|
||||
|
||||
if result['success']:
|
||||
return PromocodeDeactivateResponse(
|
||||
success=True,
|
||||
message='Discount promo code deactivated successfully',
|
||||
deactivated_code=result.get('deactivated_code'),
|
||||
discount_percent=result.get('discount_percent', 0),
|
||||
)
|
||||
|
||||
error_messages = {
|
||||
'user_not_found': 'User not found',
|
||||
'no_active_discount_promocode': 'No active discount promo code found',
|
||||
'discount_already_expired': 'Discount has already expired',
|
||||
'server_error': 'Server error occurred',
|
||||
}
|
||||
|
||||
error_code = result.get('error', 'server_error')
|
||||
error_message = error_messages.get(error_code, 'Failed to deactivate promo code')
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=error_message,
|
||||
)
|
||||
|
||||
@@ -192,6 +192,36 @@ async def delete_promocode(db: AsyncSession, promocode: PromoCode) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def get_active_discount_promocode_for_user(
|
||||
db: AsyncSession, user_id: int
|
||||
) -> tuple[PromoCode | None, PromoCodeUse | None]:
|
||||
"""
|
||||
Находит активный промокод на скидку, который сейчас действует у пользователя.
|
||||
Ищет по source-полю пользователя (формат 'promocode:CODE').
|
||||
|
||||
Returns:
|
||||
Кортеж (PromoCode, PromoCodeUse) или (None, None) если ничего не найдено.
|
||||
"""
|
||||
from app.database.models import User as UserModel
|
||||
|
||||
user_result = await db.execute(select(UserModel).where(UserModel.id == user_id))
|
||||
user = user_result.scalar_one_or_none()
|
||||
if not user:
|
||||
return None, None
|
||||
|
||||
source = getattr(user, 'promo_offer_discount_source', None)
|
||||
if not source or not source.startswith('promocode:'):
|
||||
return None, None
|
||||
|
||||
code = source.split(':', 1)[1]
|
||||
promocode = await get_promocode_by_code(db, code)
|
||||
if not promocode:
|
||||
return None, None
|
||||
|
||||
use = await get_promocode_use_by_user_and_code(db, user_id, promocode.id)
|
||||
return promocode, use
|
||||
|
||||
|
||||
async def get_promocode_statistics(db: AsyncSession, promocode_id: int) -> dict:
|
||||
total_uses_result = await db.execute(
|
||||
select(func.count(PromoCodeUse.id)).where(PromoCodeUse.promocode_id == promocode_id)
|
||||
|
||||
@@ -978,7 +978,7 @@
|
||||
"DEVICE_CHANGE_CONFIRMATION": "📱 <b>Confirm change</b>\n\nCurrent amount: {current} devices\nNew amount: {new} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?",
|
||||
"DEVICE_CHANGE_DECREASE_SUCCESS": "✅ Device limit decreased!\n\n",
|
||||
"DEVICE_CHANGE_DISCOUNT_INFO": " (discount {percent}%: -{amount})",
|
||||
"DEVICE_CHANGE_EXTRA_COST": "Extra payment: {amount} (for {months} mo)",
|
||||
"DEVICE_CHANGE_EXTRA_COST": "Extra payment: {amount} (for {period})",
|
||||
"DEVICE_CHANGE_FREE": "Free",
|
||||
"DEVICE_CHANGE_INCREASE_SUCCESS": "✅ Device limit increased!\n\n",
|
||||
"DEVICE_CHANGE_NO_REFUND": "Payments are not refunded",
|
||||
|
||||
@@ -999,7 +999,7 @@
|
||||
"DEVICE_CHANGE_CONFIRMATION": "📱 <b>Подтверждение изменения</b>\n\nТекущее количество: {current} устройств\nНовое количество: {new} устройств\n\nДействие: {action}\n💰 {cost}\n\nПодтвердить изменение?",
|
||||
"DEVICE_CHANGE_DECREASE_SUCCESS": "✅ Количество устройств уменьшено!\n\n",
|
||||
"DEVICE_CHANGE_DISCOUNT_INFO": " (скидка {percent}%: -{amount})",
|
||||
"DEVICE_CHANGE_EXTRA_COST": "Доплата: {amount} (за {months} мес)",
|
||||
"DEVICE_CHANGE_EXTRA_COST": "Доплата: {amount} (за {period})",
|
||||
"DEVICE_CHANGE_FREE": "Бесплатно",
|
||||
"DEVICE_CHANGE_INCREASE_SUCCESS": "✅ Количество устройств увеличено!\n\n",
|
||||
"DEVICE_CHANGE_NO_REFUND": "Возврат средств не производится",
|
||||
|
||||
@@ -918,7 +918,7 @@
|
||||
"DEVICE_CHANGE_CONFIRMATION": "📱 <b>Підтвердження зміни</b>\n\nПоточна кількість: {current} пристроїв\nНова кількість: {new} пристроїв\n\nДія: {action}\n💰 {cost}\n\nПідтвердити зміну?",
|
||||
"DEVICE_CHANGE_DECREASE_SUCCESS": "✅ Кількість пристроїв зменшено!\n\n",
|
||||
"DEVICE_CHANGE_DISCOUNT_INFO": " (знижка {percent}%: -{amount})",
|
||||
"DEVICE_CHANGE_EXTRA_COST": "Доплата: {amount} (за {months} міс)",
|
||||
"DEVICE_CHANGE_EXTRA_COST": "Доплата: {amount} (за {period})",
|
||||
"DEVICE_CHANGE_FREE": "Безкоштовно",
|
||||
"DEVICE_CHANGE_INCREASE_SUCCESS": "✅ Кількість пристроїв збільшено!\n\n",
|
||||
"DEVICE_CHANGE_NO_REFUND": "Повернення коштів не здійснюється",
|
||||
|
||||
@@ -916,7 +916,7 @@
|
||||
"DEVICE_CHANGE_CONFIRMATION":"📱<b>确认更改</b>\n\n当前数量:{current}台设备\n新数量:{new}台设备\n\n操作:{action}\n💰{cost}\n\n确认更改吗?",
|
||||
"DEVICE_CHANGE_DECREASE_SUCCESS":"✅设备数量已减少!\n\n",
|
||||
"DEVICE_CHANGE_DISCOUNT_INFO":"(折扣{percent}%:-{amount})",
|
||||
"DEVICE_CHANGE_EXTRA_COST":"补差价:{amount}(共{months}个月)",
|
||||
"DEVICE_CHANGE_EXTRA_COST":"补差价:{amount}({period})",
|
||||
"DEVICE_CHANGE_FREE":"免费",
|
||||
"DEVICE_CHANGE_INCREASE_SUCCESS":"✅设备数量已增加!\n\n",
|
||||
"DEVICE_CHANGE_NO_REFUND":"不退款",
|
||||
|
||||
@@ -7,6 +7,7 @@ from app.database.crud.promo_group import get_promo_group_by_id
|
||||
from app.database.crud.promocode import (
|
||||
check_user_promocode_usage,
|
||||
create_promocode_use,
|
||||
get_active_discount_promocode_for_user,
|
||||
get_promocode_by_code,
|
||||
)
|
||||
from app.database.crud.subscription import extend_subscription, get_subscription_by_user_id
|
||||
@@ -300,3 +301,102 @@ class PromoCodeService:
|
||||
effects.append('ℹ️ У вас уже есть активная подписка')
|
||||
|
||||
return '\n'.join(effects) if effects else '✅ Промокод активирован'
|
||||
|
||||
async def deactivate_discount_promocode(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
*,
|
||||
admin_initiated: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Деактивирует активный промокод на процентную скидку у пользователя.
|
||||
|
||||
Действия:
|
||||
- Сбрасывает promo_offer_discount_percent / source / expires_at на пользователе
|
||||
- Удаляет запись PromoCodeUse (чтобы промокод мог быть повторно использован, если max_uses > current_uses)
|
||||
- Декрементирует current_uses на промокоде
|
||||
- Если промокод назначил промогруппу -- снимает её с пользователя
|
||||
|
||||
Args:
|
||||
db: Сессия БД
|
||||
user_id: ID пользователя
|
||||
admin_initiated: True если деактивацию инициировал админ
|
||||
|
||||
Returns:
|
||||
dict с ключами success, error (опционально), deactivated_code (опционально)
|
||||
"""
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
return {'success': False, 'error': 'user_not_found'}
|
||||
|
||||
current_discount = getattr(user, 'promo_offer_discount_percent', 0) or 0
|
||||
source = getattr(user, 'promo_offer_discount_source', None)
|
||||
|
||||
if current_discount <= 0 or not source or not source.startswith('promocode:'):
|
||||
return {'success': False, 'error': 'no_active_discount_promocode'}
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
expires_at = getattr(user, 'promo_offer_discount_expires_at', None)
|
||||
# Если скидка уже истекла по времени -- тоже нечего деактивировать
|
||||
if expires_at is not None and expires_at <= datetime.utcnow():
|
||||
# Просто зачистим протухшие данные
|
||||
user.promo_offer_discount_percent = 0
|
||||
user.promo_offer_discount_source = None
|
||||
user.promo_offer_discount_expires_at = None
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
return {'success': False, 'error': 'discount_already_expired'}
|
||||
|
||||
promocode, promo_use = await get_active_discount_promocode_for_user(db, user_id)
|
||||
|
||||
deactivated_code = source.split(':', 1)[1]
|
||||
|
||||
# 1. Сбрасываем скидку на пользователе
|
||||
user.promo_offer_discount_percent = 0
|
||||
user.promo_offer_discount_source = None
|
||||
user.promo_offer_discount_expires_at = None
|
||||
user.updated_at = datetime.utcnow()
|
||||
|
||||
# 2. Откатываем использование промокода (если нашли запись)
|
||||
if promocode and promo_use:
|
||||
await db.delete(promo_use)
|
||||
if promocode.current_uses > 0:
|
||||
promocode.current_uses -= 1
|
||||
promocode.updated_at = datetime.utcnow()
|
||||
|
||||
# 3. Если промокод назначал промогруппу -- снимаем её
|
||||
if promocode.promo_group_id:
|
||||
from app.database.crud.user_promo_group import (
|
||||
has_user_promo_group,
|
||||
remove_user_from_promo_group,
|
||||
)
|
||||
|
||||
has_group = await has_user_promo_group(db, user_id, promocode.promo_group_id)
|
||||
if has_group:
|
||||
await remove_user_from_promo_group(db, user_id, promocode.promo_group_id)
|
||||
logger.info(
|
||||
f'Снята промогруппа ID {promocode.promo_group_id} у пользователя '
|
||||
f'{self._format_user_log(user)} при деактивации промокода {deactivated_code}'
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
initiator = 'администратором' if admin_initiated else 'пользователем'
|
||||
logger.info(
|
||||
f'Промокод {deactivated_code} (скидка {current_discount}%) деактивирован '
|
||||
f'{initiator} для пользователя {self._format_user_log(user)}'
|
||||
)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'deactivated_code': deactivated_code,
|
||||
'discount_percent': current_discount,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка деактивации промокода для пользователя {user_id}: {e}')
|
||||
await db.rollback()
|
||||
return {'success': False, 'error': 'server_error'}
|
||||
|
||||
Reference in New Issue
Block a user