Django отнимание единиц товара, как безопаснее и правильно?

Пытаюсь реализовать простой магазин и у меня возник вопрос:

Предыстория:

  1. Я делаю валидацию формы (в классе формы) и если пользователь попытается купить товара больше, чем есть, то ему вернет форму с ошибкой.

  2. Если такая проблема не возникла на моменте валидации формы, то дальше в методе 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 шт):

Автор решения: Roman Konoval

Будет проблема в месте, где будете делать собственно операцию изменения, и гонки возможны.

В форме нужно проверять формат данных, и допустимость значений (например, что 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)
→ Ссылка