Django отнимание единиц товара, как безопаснее и правильно?
Пытаюсь реализовать простой магазин и у меня возник вопрос:
Предыстория:
Я делаю валидацию формы (в классе формы) и если пользователь попытается купить товара больше, чем есть, то ему вернет форму с ошибкой.
Если такая проблема не возникла на моменте валидации формы, то дальше в методе form_valid() моего класса Представления (ProductDetail) у данного товара отнимается количество, которое купил пользователь.
И собственно вопрос, нужно ли еще раз на 2 пункте делать проверку на то, достаточно ли товара на складе (непосредственно в представлении) или же достаточно только сделать эту проверку на 1 пункте?
Не может ли возникнуть условной гонки, которая приведет к двойную отниманию товара? Или другой проблеме связанной с транзакциями? Если допустим два пользователя одновременно попробуют купить 10 и 10 единиц, когда их всего 10 на складе.
Ниже прикрепил код и пометил коментарием спорное место.
Мой класс представления:
class ProductDetail(FormView):
form_class = SaleForm
template_name = "shop/product_detail.html"
success_url = reverse_lazy('product_list')
def get_initial(self):
initial = super(ProductDetail, self).get_initial()
initial.update({'amount': 1, 'product_id': self.kwargs['product_id']})
return initial
def dispatch(self, request, *args, **kwargs):
product = self.check_product_exist(self.request, self.kwargs['product_id'])
if not product: return redirect('product_list')
sellers_qs = self.check_seller_exist(self.request, product)
if not sellers_qs: return redirect('product_list')
self.kwargs['product'] = product
self.kwargs['sellers_qs'] = sellers_qs
return super(ProductDetail, self).dispatch(request, *args, **kwargs)
def get_form_kwargs(self):
form_kwargs = super(ProductDetail, self).get_form_kwargs()
form_kwargs['sellers_qs'] = self.kwargs['sellers_qs']
form_kwargs['max_amount'] = self.kwargs['product'].amount
return form_kwargs
def form_valid(self, form):
product = self.check_product_exist(self.request, form.cleaned_data['product_id'])
"""
(!) Нужно ли здесь повторно делать проверку на то, есть ли на складе достаточное кол-во единиц товара!?
"""
product.amount -= form.cleaned_data['amount']
product.save()
Sale.objects.create(seller=form.cleaned_data['sellers'], product=product, amount_sold=form.cleaned_data['amount'],
purchase_amount=product.price * form.cleaned_data['amount'])
return super(ProductDetail, self).form_valid(form)
def get_context_data(self, **kwargs):
product = self.check_product_exist(self.request, self.kwargs['product_id'])
context = super().get_context_data(**kwargs)
context["product"] = product
return context
def check_product_exist(self, request, product_id):
try:
product = Product.objects.filter(pk=product_id)[0]
return product
except IndexError:
messages.add_message(request, messages.ERROR, 'Нужный товар не был найден, возможно он был удален.')
return None
def check_seller_exist(self, request, product):
sellers_qs = Seller.objects.filter(product=product.pk)
if sellers_qs: return sellers_qs
messages.add_message(request, messages.ERROR, 'Продавцы для данной позиции отсутствуют в базе данных.')
return None
Мой класс формы:
class SaleForm(forms.Form):
amount = forms.IntegerField(label='Кол-во', min_value=1)
product_id = forms.IntegerField(widget=forms.HiddenInput())
sellers = forms.ModelChoiceField(label='Продавцы', queryset=Seller.objects.none())
def clean(self):
cleaned_data = super().clean()
amount = cleaned_data.get("amount")
product_id = cleaned_data.get("product_id")
seller = cleaned_data.get("sellers")
if amount < 1:
raise forms.ValidationError(
"Указано неверное количесто товара."
)
if not Product.objects.filter(pk=product_id):
raise forms.ValidationError(
"Товар не найден в базе данных."
)
if Product.objects.get(pk=product_id).amount < amount:
raise forms.ValidationError(
"На складе нет столько единиц товара, выберите другое количество."
)
if not Seller.objects.filter(name=seller):
raise forms.ValidationError(
"Продавец не найден в базе данных."
)
if not Seller.objects.filter(product=product_id):
raise forms.ValidationError(
"Продавец не продает данный товар."
)
def __init__(self, *args, **kwargs):
# Устанавливаем в форме продавцов, которые были переданны и максимальное кол-во товара, которое можно выбрать в форме.
qs = kwargs.pop('sellers_qs')
max_amount = kwargs.pop('max_amount')
super(SaleForm, self).__init__(*args, **kwargs)
self.fields['sellers'].queryset = qs
self.fields['amount'].max_value = max_amount
self.fields['amount'].widget.attrs['max'] = max_amount
Ответы (1 шт):
Будет проблема в месте, где будете делать собственно операцию изменения, и гонки возможны.
В форме нужно проверять формат данных, и допустимость значений (например, что amount > 0), а бизнес-логику (тем более с запросами в БД) проверять не нужно.
Сам подход с проверкой типа if Product.objects.get(pk=product_id).amount < amount: работать не будет, так как и здесь есть гонки.
Первый (простой) вариант, как делать правильно, это блокировать запись, когда мы ее читаем, чтоб никакая другая транзакция не могла ее поменять:
with transaction.atomic():
product = Product.objects.select_for_update().get(pk=product_id)
if product.amount < amount:
# Обрабатываем ошибку - мало денег
# проверяем еще условия
if ...
# меняем как хотим
product.amount -= amount
product.save()
Второй способ, это делать оптимистическую блокировку. Про общий подход можно тут почитать. Не буду тут расписывать детали (можно найти на ruSO). Для этого нужно добавить в сущность поле version и проверять при сохранении, что поле не менялось с момента, когда мы сущность прочитали.
И последний способ. В некоторых случаях можно обойтись без транзакции и делать атомарное изменение одним запросом:
Product.objects.filter(pk=product_id).update(likes=F('likes') + 1)