Fix race condition and expired lock validation for showcase kit cart

- Add select_for_update() with transaction.atomic() to prevent race condition
  when multiple users try to add the same showcase kit simultaneously
- Validate cart lock expiration when loading cart from Redis - showcase kits
  with expired locks are now filtered out instead of being displayed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-22 19:00:48 +03:00
parent c856d5d50a
commit 38ee2f5df7

View File

@@ -227,10 +227,25 @@ def pos_terminal(request):
# Проверяем что товар существует
Product.objects.get(id=item['id'])
cart_data[cart_key] = item
elif item['type'] in ('kit', 'showcase_kit'):
# Проверяем что комплект существует
elif item['type'] == 'kit':
# Обычный комплект - только проверяем существование
ProductKit.objects.get(id=item['id'])
cart_data[cart_key] = item
elif item['type'] == 'showcase_kit':
# Витринный комплект - проверяем существование И актуальность блокировки
kit = ProductKit.objects.get(id=item['id'])
# Проверяем, что блокировка всё ещё активна для этого пользователя
has_valid_lock = Reservation.objects.filter(
product_kit=kit,
locked_by_user=request.user,
cart_lock_expires_at__gt=timezone.now(),
status='reserved'
).exists()
if has_valid_lock:
cart_data[cart_key] = item
# Если блокировка истекла - не добавляем в корзину (товар доступен другим)
except (Product.DoesNotExist, ProductKit.DoesNotExist):
# Товар или комплект удален - пропускаем
continue
@@ -468,43 +483,45 @@ def add_showcase_kit_to_cart(request, kit_id):
status='active'
)
# Проверяем существующие блокировки
existing_locks = Reservation.objects.filter(
product_kit=kit,
cart_lock_expires_at__gt=timezone.now(),
status='reserved'
).exclude(
locked_by_user=request.user
).select_related('locked_by_user')
# Атомарная проверка и создание блокировки (предотвращает race condition)
with transaction.atomic():
# Блокируем строки резервов для этого комплекта на уровне БД
reservations = Reservation.objects.select_for_update().filter(
product_kit=kit,
status='reserved'
)
if existing_locks.exists():
lock = existing_locks.first()
time_left = (lock.cart_lock_expires_at - timezone.now()).total_seconds() / 60
return JsonResponse({
'success': False,
'error': f'Этот букет уже в корзине кассира "{lock.locked_by_user.username}". '
f'Блокировка истечет через {int(time_left)} мин.'
}, status=409) # 409 Conflict
# Проверяем существующие блокировки другими пользователями
existing_lock = reservations.filter(
cart_lock_expires_at__gt=timezone.now()
).exclude(
locked_by_user=request.user
).select_related('locked_by_user').first()
# Создаем или продлеваем блокировку для текущего пользователя
lock_expires_at = timezone.now() + timedelta(minutes=30)
session_id = request.session.session_key or ''
if existing_lock:
time_left = (existing_lock.cart_lock_expires_at - timezone.now()).total_seconds() / 60
return JsonResponse({
'success': False,
'error': f'Этот букет уже в корзине кассира "{existing_lock.locked_by_user.username}". '
f'Блокировка истечет через {int(time_left)} мин.'
}, status=409) # 409 Conflict
# Обновляем все резервы этого комплекта
updated_count = Reservation.objects.filter(
product_kit=kit,
status='reserved'
).update(
cart_lock_expires_at=lock_expires_at,
locked_by_user=request.user,
cart_session_id=session_id
)
# Создаем или продлеваем блокировку для текущего пользователя
lock_expires_at = timezone.now() + timedelta(minutes=30)
session_id = request.session.session_key or ''
if updated_count == 0:
return JsonResponse({
'success': False,
'error': 'У комплекта нет активных резервов. Возможно, он уже продан.'
}, status=400)
# Обновляем все резервы этого комплекта (теперь атомарно!)
updated_count = reservations.update(
cart_lock_expires_at=lock_expires_at,
locked_by_user=request.user,
cart_session_id=session_id
)
if updated_count == 0:
return JsonResponse({
'success': False,
'error': 'У комплекта нет активных резервов. Возможно, он уже продан.'
}, status=400)
return JsonResponse({
'success': True,