pythondjangodjango-modelsmodelformdjango-validation

Validating a model based on the value set for a separate model


Background

I am creating an E-commerce application that allows users to submit bids on any active item listings – where Bid & Listing are distinct Django models in models.py (below).

For each new bid, the bid amount (Bid.amount) is set via a Model Form. If the bid amount is greater than the current bid (Listing.current_bid), then:

  1. The Bid is considered valid and the object is saved.
  2. Listing.current_bid is updated to equal the new Bid.amount.

Issue

Currently, my solution (see views.py below) achieves the desired result – it successfully validates and updates each new bid using a conditional concatenated with the call to is_valid():

if new_bid_form.is_valid() and new_bid_form.cleaned_data["current_bid"] > previous_bid:

However, even though this design works, it feels "hackish", because the validation logic does not occur within the model. Therefore, the condition is not implicitly checked on the call to clean(), and I can foresee that causing issues going forward. Instead, I want to implement the validation logic within the model so that its called with Django's built-in validation and will raise a ValidationError.


Question

  1. Am I correct in saying that my current "validation" solution is poorly designed?
  2. If so, how can I validate within the Bid model, that the value entered into the form for Bid.amount is greater than Listing.current_bid? (See Solution Attempts below)

models.py

Listing Model

class Listing(models.Model):
    author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="usernames")
    title = models.CharField(max_length=80)
    description = models.CharField(max_length=800)
    starting_bid = models.DecimalField(max_digits=11, decimal_places=2,validators=[MinValueValidator(Decimal('0.00'))])
    current_bid = models.DecimalField(default=0, max_digits=11, decimal_places=2,validators=[MinValueValidator(Decimal('0.00'))])
    image = models.URLField(blank=True)

Bid Model

 class Bid(models.Model):
        owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="owners")
        listing = models.ForeignKey(Listing, on_delete=models.CASCADE, related_name="listings")
        amount = models.DecimalField(max_digits=11, decimal_places=2,validators=[MinValueValidator(Decimal('0.00'))])

views.py

listing() Function: Defines a view for each listing & implements a ModelForm to submit bids.

def listing(request, listing_id):
    class NewBidForm(ModelForm):
        template_name = "auctions/form_template.html"
        class Meta:
            model = Bid
            fields = ["amount"]
            widgets = {
                "amount":forms.NumberInput(attrs={"placeholder": "Submit an offer greater than the current listing price"})
            }
            labels = {
                "amount":_("Submit an Offer:")
            }
    
    # Get the current bid for the current listing
    listing = Listing.objects.get(pk=listing_id)
    current_bid = listing.current_bid

    # If the submitted form data is valid, update & save new bid
    if request.method == "POST":
        new_bid_form = NewBidForm(request.POST)
        if new_bid_form.is_valid() and new_bid_form.cleaned_data["amount"] > current_bid:
            new_bid_form.instance.amount = new_bid_form.cleaned_data["amount"]
            new_bid_form.instance.owner = request.user
            new_bid_form.instance.listing = listing
            new_bid_form.save()

            # Set the listing's current bid to the new bid amount
            listing.current_bid = new_bid_form.instance.amount
            listing.save()
            return HttpResponseRedirect(reverse("listing", args=[listing_id]))
        else:
            return render(request, "auctions/listing.html", {
                "listing": listing,
                "form": new_bid_form,
                "message": messages.add_message(request, messages.ERROR, "Bid must be greater than the current highest bid."),
            })

Solution Attempts

So far, I've been unable to find a similar example that provided any hints at a solution that I could iterate on. From the Django documentation, it seems overriding the clean() method might potentially be a way to achieve this, but I have not found a way to do this when one (unsaved) model instance, depends on an instance of another distinct model.

I attempted a solution that used the clean() method below in my Bid model:

def clean(self):
        super().clean()
        if self.amount < self.listing.current_bid:
            raise ValidationError("Bid must be greater than the previous current bid")

However, this returned an error, which I believe is because the new Bid is not yet saved, thus does not have a reference to .listing:

RelatedObjectDoesNotExist at /listing/1. Bid has no listing.

Any insight would be massively appreciated!


Solution

  • For anyone else that might have a similar issue, I was able to find a solution (which was staring me in the face) by overriding the clean() method as I had guessed.

    The error was raised because the Bid object did not yet have a reference to Listing at the time the overridden clean() method was being invoked.

    Therefore, by instantiating the Bid with a reference to the listing object, I was able to solve the issue:

    def listing(request, listing_id):
        class NewBidForm(ModelForm):
            template_name = "auctions/form_template.html"
            class Meta:
                model = Bid
                fields = ["amount"]
                widgets = {
                    "amount":forms.NumberInput(attrs={"placeholder": "Submit an offer greater than the current listing price"})
                }
                labels = {
                    "amount":_("Submit an Offer:")
                }
        
        # Get the current listing to bid on.
        listing = Listing.objects.get(pk=listing_id)
    
        # Validate that the new bid is greater than the current highest bid.
        if request.method == "POST":
            new_bid_form = NewBidForm(request.POST, instance=Bid(listing=listing))
            if new_bid_form.is_valid():
                new_bid_form.instance.amount = new_bid_form.cleaned_data["amount"]
                new_bid_form.instance.owner = request.user
                new_bid_form.save()
    
                # Set the listing's current bid to the new bid amount.
                listing.current_bid = new_bid_form.instance.amount
                listing.save()
                return HttpResponseRedirect(reverse("listing", args=[listing_id]))
            else:
                return render(request, "auctions/listing.html", {
                    "listing": listing,
                    "form": new_bid_form,
                })
    

    For reference, a slight revision to the clean() method in the Bid model:

    class Bid(models.Model):
        owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="owners")
        listing = models.ForeignKey(Listing, on_delete=models.CASCADE, related_name="listings")
        amount = models.DecimalField(max_digits=11, decimal_places=2,validators=[MinValueValidator(Decimal('0.00'))])
    
        def clean(self):
            super().clean()
            if self.amount <= self.listing.current_bid:
                raise ValidationError(
                    _("Unable to place new bid! Please enter a minimum bid of at least $%(value)s!"), 
                    params={"value":f"{float(self.listing.current_bid) + 1.0:.2f}"})