Django's ModelForm unique_together validation
Asked Answered
E

10

80

I have a Django model that looks like this.

class Solution(models.Model):
    '''
    Represents a solution to a specific problem.
    '''
    name = models.CharField(max_length=50)
    problem = models.ForeignKey(Problem)
    description = models.TextField(blank=True)
    date = models.DateTimeField(auto_now_add=True)

    class Meta:
        unique_together = ("name", "problem")

I use a form for adding models that looks like this:

class SolutionForm(forms.ModelForm):
    class Meta:
        model = Solution
        exclude = ['problem']

My problem is that the SolutionForm does not validate Solution's unique_together constraint and thus, it returns an IntegrityError when trying to save the form. I know that I could use validate_unique to manually check for this but I was wondering if there's any way to catch this in the form validation and return a form error automatically.

Thanks.

Eosin answered 26/1, 2010 at 17:1 Comment(2)
Are you sure that you set up everything correctly because the documentation about model forms syas clearly: "By default the clean() method validates the uniqueness of fields that are marked as unique, unique_together or unique_for_date|month|year on the model. " docs.djangoproject.com/en/1.1/topics/forms/modelforms/…Mate
can you try it without the exclude part? manually select the problem that i assume is determined by your view.Guyette
E
33

I managed to fix this without modifying the view by adding a clean method to my form:

class SolutionForm(forms.ModelForm):
    class Meta:
        model = Solution
        exclude = ['problem']

    def clean(self):
        cleaned_data = self.cleaned_data

        try:
            Solution.objects.get(name=cleaned_data['name'], problem=self.problem)
        except Solution.DoesNotExist:
            pass
        else:
            raise ValidationError('Solution with this Name already exists for this problem')

        # Always return cleaned_data
        return cleaned_data

The only thing I need to do now in the view is to add a problem property to the form before executing is_valid.

Eosin answered 28/1, 2010 at 16:3 Comment(3)
Don't use a bare except clause. This will pass even if the exception is due to the database server being hit by a meteor. Instead, use "except Solution.DoesNotExist:".Renunciation
This worked for me. Unlike OP however, my two fields were regular fields and I did not had to exclude either of the fields in the form META's exclude` attribute.Sunderland
One minor issue with this technique is with race conditions. The Solution object might be inserted after this check, and if it is, you will still see an IntegrityError. I think to avoid this you could just catch the error on save.Laterality
L
40

I solved this same problem by overriding the validate_unique() method of the ModelForm:


def validate_unique(self):
    exclude = self._get_validation_exclusions()
    exclude.remove('problem') # allow checking against the missing attribute

    try:
        self.instance.validate_unique(exclude=exclude)
    except ValidationError, e:
        self._update_errors(e.message_dict)

Now I just always make sure that the attribute not provided on the form is still available, e.g. instance=Solution(problem=some_problem) on the initializer.

Lysimeter answered 21/9, 2010 at 6:40 Comment(1)
Note that this only validates any forms for this model, while unique_together is used in the underlying database. That means that anything that uses the model objects directly are not bound by this validation.Hersh
E
33

I managed to fix this without modifying the view by adding a clean method to my form:

class SolutionForm(forms.ModelForm):
    class Meta:
        model = Solution
        exclude = ['problem']

    def clean(self):
        cleaned_data = self.cleaned_data

        try:
            Solution.objects.get(name=cleaned_data['name'], problem=self.problem)
        except Solution.DoesNotExist:
            pass
        else:
            raise ValidationError('Solution with this Name already exists for this problem')

        # Always return cleaned_data
        return cleaned_data

The only thing I need to do now in the view is to add a problem property to the form before executing is_valid.

Eosin answered 28/1, 2010 at 16:3 Comment(3)
Don't use a bare except clause. This will pass even if the exception is due to the database server being hit by a meteor. Instead, use "except Solution.DoesNotExist:".Renunciation
This worked for me. Unlike OP however, my two fields were regular fields and I did not had to exclude either of the fields in the form META's exclude` attribute.Sunderland
One minor issue with this technique is with race conditions. The Solution object might be inserted after this check, and if it is, you will still see an IntegrityError. I think to avoid this you could just catch the error on save.Laterality
S
32

As Felix says, ModelForms are supposed to check the unique_together constraint in their validation.

However, in your case you are actually excluding one element of that constraint from your form. I imagine this is your problem - how is the form going to check the constraint, if half of it is not even on the form?

Spermary answered 26/1, 2010 at 20:19 Comment(2)
Indeed that was the problem. So I guess that I can't get an error on the form without also including the problem field and that I'll have to manually check for this case.Eosin
Then how we can solve this problem ? We want to exclude ForeignKey field from modelform but in view store the ForeignKey in modelform because we don't give user to access to store ForeignKey in modelform.Arlana
B
11

the solution from @sttwister is right but can be simplified.

class SolutionForm(forms.ModelForm):

    class Meta:
        model = Solution
        exclude = ['problem']

    def clean(self):
        cleaned_data = self.cleaned_data
        if Solution.objects.filter(name=cleaned_data['name'],         
                                   problem=self.problem).exists():
            raise ValidationError(
                  'Solution with this Name already exists for this problem')

        # Always return cleaned_data
        return cleaned_data

As a bonus you do not retreive the object in case of duplicate but only check if it exists in the database saving a little bit of performances.

Blithesome answered 5/5, 2015 at 10:3 Comment(0)
B
1

With the help of Jarmo's answer, the following seems to work nicely for me (in Django 1.3), but it's possible I've broken some corner case (there are a lot of tickets surrounding _get_validation_exclusions):

class SolutionForm(forms.ModelForm):
    class Meta:
        model = Solution
        exclude = ['problem']

    def _get_validation_exclusions(self):
        exclude = super(SolutionForm, self)._get_validation_exclusions()
        exclude.remove('problem')
        return exclude

I'm not sure, but this seems like a Django bug to me... but I'd have to look around the previously-reported issues.


Edit: I spoke too soon. Maybe what I wrote above will work in some situations, but not in mine; I ended up using Jarmo's answer directly.

Balaam answered 5/8, 2011 at 22:35 Comment(0)
H
0

You will need to do something like this:

def your_view(request):
    if request.method == 'GET':
        form = SolutionForm()
    elif request.method == 'POST':
        problem = ... # logic to find the problem instance
        solution = Solution(problem=problem) # or solution.problem = problem
        form = SolutionForm(request.POST, instance=solution)
        # the form will validate because the problem has been provided on solution instance
        if form.is_valid():
            solution = form.save()
            # redirect or return other response
    # show the form
Henke answered 27/1, 2010 at 9:54 Comment(1)
The form still doesn't validate the unique_together constraint, probably because problem is mentioned in the exclude property, even though it has a valid instanceEosin
L
0

If you want the error message to be a associated with the name field (and appear next to it):

def clean(self):
    cleaned_data = super().clean()
    name_field = 'name'
    name = cleaned_data.get(name_field)

    if name:
        if Solution.objects.filter(name=name, problem=self.problem).exists():
            cleaned_data.pop(name_field)  # is also done by add_error
            self.add_error(name_field, _('There is already a solution with this name.'))

    return cleaned_data
Loop answered 15/1, 2016 at 9:45 Comment(0)
S
0

My solution is based off Django 2.1

Leave SolutionForm alone, have a save() method in Solution

class Solution(models.Model):
...
   def save(self, *args, **kwargs):
      self.clean()
      return super(Solution, self).save(*args, **kwargs)


  def clean():
      # have your custom model field checks here
      # They can raise Validation Error

      # Now this is the key to enforcing unique constraint
      self.validate_unique()

Calling full_clean() in save() does not work as the ValidationError will be unhandled

Schwitzer answered 26/4, 2019 at 20:57 Comment(0)
M
0

I needed to exclude the company field in my case and add it in the view's form_valid function. I ended up doing the following (taking inspiration from different answers). In my CreateView

    def form_valid(self, form):
        cleaned_data = form.cleaned_data
        user_company = self.request.user.profile.company
        if UnitCategory.objects.filter(code=cleaned_data['code'],
                                    company=user_company).exists():
            form.add_error('code',                           _(
                'A UnitCategory with this Code already exists for this company.'))
            return super(UnitCategoryCreateView, self).form_invalid(form)
        if UnitCategory.objects.filter(color=cleaned_data['color'],
                                    company=user_company).exists():
            form.add_error('color',                           _(
                'A UnitCategory with this Color already exists for this company.'))
            return super(UnitCategoryCreateView, self).form_invalid(form)
        form.instance.company = user_company
        return super(UnitCategoryCreateView, self).form_valid(form)

In my UpdateView I had to exclude the current instance of the object in checking if the query exist using exclude(pk=self.kwargs['pk'])

    def form_valid(self, form):
        cleaned_data = form.cleaned_data
        user_company = self.request.user.profile.company
        if UnitCategory.objects.filter(code=cleaned_data['code'],
                                       company=user_company).exclude(pk=self.kwargs['pk']).exists():
            form.add_error(
                'code', _('A UnitCategory with this Code already exists for this company.'))
            return super(UnitCategoryUpdateView, self).form_invalid(form)
        if UnitCategory.objects.filter(color=cleaned_data['color'],
                                       company=user_company).exclude(pk=self.kwargs['pk']).exists():
            form.add_error('color', _(
                'A UnitCategory with this Color already exists for this company.'))
            return super(UnitCategoryUpdateView, self).form_invalid(form)
        # Return form_valid if no errors raised
        # Add logged-in user's company as form's company field
        form.instance.company = user_company
        return super(UnitCategoryUpdateView, self).form_valid(form)

Not the cleanest solution I was hoping for, but thought it might benefit someone.

Monorail answered 6/9, 2019 at 0:0 Comment(0)
A
0

If you always want to check the uniqueness constraint (i.e. on every ModelForm you create), you can make sure the uniqueness constraint is always validated on the model:

# Always check name-problem uniqueness
def validate_unique(self, exclude=None):
    if exclude is None:
        exclude = []

    try:
        exclude.remove("problem")
    except ValueError:
        pass

    return super().validate_unique(exclude)

This works because the model's validate_unique method is called during form validation.

Alagez answered 6/5, 2022 at 14:50 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.