From 38ee2f5df70ba7507802ec25ac39ef43a104bf55 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Sat, 22 Nov 2025 19:00:48 +0300 Subject: [PATCH] Fix race condition and expired lock validation for showcase kit cart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- myproject/pos/views.py | 87 +++++++++++++++++++++++++----------------- 1 file changed, 52 insertions(+), 35 deletions(-) diff --git a/myproject/pos/views.py b/myproject/pos/views.py index d52b73f..fd8b278 100644 --- a/myproject/pos/views.py +++ b/myproject/pos/views.py @@ -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,