Check if model exist before save to avoid manually handling IntegrityError

That is indeed correct. The correct should be:

form_a_set = set(range(form_a_sr.lower, form_a_sr.upper))
form_b_set = set(range(form_b_sr.lower, form_b_sr.upper))

Still haven’t figured out why it doesn’t show me the validation error.

Assuming you haven’t fixed handle_forms as mentioned previously, you’ve still got an issue with handling these errors. You’re still trying to catch these errors as an exception - but the is_valid test handles the exceptions and does not propagate them.

The errors reported from forms is in the Form.errors object, and not propagated as exceptions.

Nope, I already made the same* clean() method on both models (Bins and BinsHistory), I’m not getting IntegrityErrors anymore, but I’m not getting an expected form error (ValidationError) from the bins_formset.is_valid()/bins_history_formset.is_valid(). I’ve changed my formset clean method to raise ValidationErrors regardless, and while if formset.is_valid() fails, the form isn’t getting populated with any error message.

if bins_formset.is_valid():
    bins = bins_formset.save(commit=False)
    for bin_instance in bins:
        bin_instance.parent_transaction = transaction_instance
        bin_instance.located_at = transaction_instance.recieving_warehouse
        bin_instance.save()
        print('bin_instance is being saved' + bin_instance.errors)

print('After bins_formset.is_valid()' + str(bins_formset.errors))

This only outputs:

After bins_formset.is_valid() [{}, {}, {}, {}, {}, {}]

Then I would need to see current copies of all the code involved here. This would include the entire view, forms, formsets, models, and supporting functions. (I know you’ve been working on things along the way, so I don’t want to try and piece a picture together from the different elements posted previously.)

I’d also suggest that you run this through a debugger or start liberally sprinkling print statements through this to start getting a picture of the flow of events through this process.

Note: I’m aware that there’s no condition for the ValidationError, this is deliberate so I can see what’s failing with it (why it isn’t shown in the form). As said in my previous post, formset.is_valid() correctly returns false, but still formset.errors is empty (it doesn’t print the inside of bin_instance, but does print bin_formset).

View:

class DocumentCreationView(View):
    template_name = "manager/document_creation_template.html"
    creating_warehouse = Warehouse.objects.filter(
        can_create=True).first()
    contact_list = Contact.objects.filter(
        deleted_at__isnull=True,
        authorized_warehouse__isnull=True,
    )
    recieving_warehouse = Warehouse.objects.filter(can_recieve=True)

    def get(self, request):
        transaction_form = DocumentCreationForm()
        bins_formset = BinsFormset()
        context = {
            'transaction_form': transaction_form,
            'bins_formset': bins_formset,
        }

        return render(request, self.template_name, context)

    @transaction.atomic
    def handle_forms(self,
                     transaction_form,
                     bins_formset,
                     bins_history_formset):
        if transaction_form.is_valid():
            transaction_instance = transaction_form.save(commit=False)
            transaction_instance.sending_warehouse = self.creating_warehouse
            # Change for user
            transaction_instance.started_by = transaction_instance.sending_contact
            transaction_instance.save()
            bins = None
            if bins_formset.is_valid():
                bins = bins_formset.save(commit=False)
                for bin_instance in bins:
                    bin_instance.parent_transaction = transaction_instance
                    bin_instance.located_at = transaction_instance.recieving_warehouse
                    bin_instance.save()
                    print('bin_instance is being saved' +
                          bin_instance.errors)
                    
            print('After bins_formset.is_valid()' + str(bins_formset.errors))

            if bins_history_formset.is_valid():
                bins_history = bins_history_formset.save(commit=False)
                for bin_history in bins_history:
                    bin_history.parent_transaction = transaction_instance
                    bin_history.save()

                return HttpResponseRedirect(
                    reverse('manager:set_transfered_bins'))
            print('After bins_history_formset.is_valid()' + str(bins_history_formset.errors))

    def post(self, request):
        transaction_form = DocumentCreationForm(request.POST)
        bins_formset = BinsFormset(
            request.POST,
            prefix='bins_set')
        bins_history_formset = BinsHistoryFormset(
            request.POST,
            prefix='bins_set')
        self.handle_forms(
            transaction_form,
            bins_formset,
            bins_history_formset)
        context = {
            'transaction_form': transaction_form,
            'bins_formset': bins_formset,
        }
        return render(
            request, self.template_name, context)
class OverlappingInlineFormSet(forms.BaseInlineFormSet):
    def clean(self):
        super().clean()
        for form_a, form_b in combinations(self.forms, 2):
            form_a_dt = form_a.cleaned_data['document_type']
            form_b_dt = form_b.cleaned_data['document_type']
            form_a_s = form_a.cleaned_data['series']
            form_b_s = form_b.cleaned_data['series']
            form_a_sr = form_a.cleaned_data['serial_range']
            form_b_sr = form_b.cleaned_data['serial_range']
            form_a_set = set(range(form_a_sr.lower, form_a_sr.upper))
            form_b_set = set(range(form_b_sr.lower, form_b_sr.upper))

            # This is deliberated here. I'm trying to make sure the error is shown.
            raise forms.ValidationError(
                _('Range %(form_b_range)s already exist for '
                  'the series %(form_a_series)s of the type '
                  '%(form_a_document)s is already used for'
                  'range %(form_b_range)s.'),
                params={'form_a_series': form_a_s,
                        'form_a_range': form_a_sr,
                        'form_a_document': form_a_dt,
                        'form_b_series': form_b_s,
                        'form_b_range': form_a_sr,
                        'form_b_document': form_b_dt},
                code='overlapping_form_ranges',
            )

class BinsForm(forms.ModelForm):
    class Meta:
        model = Bins
        fields = ['document_type', 'series',
                  'serial_range']

class BinHistoryForm(forms.ModelForm):
    class Meta:
        model = BinsHistory
        fields = ['document_type', 'series',
                  'serial_range']

BinsFormset = forms.inlineformset_factory(
    Transactions, Bins, form=BinsForm,
    extra=6, can_delete=False, max_num=12,
    formset=OverlappingInlineFormSet)

BinsHistoryFormset = forms.inlineformset_factory(
    Transactions, BinsHistory, form=BinHistoryForm,
    extra=60, can_delete=False, max_num=60,
    formset=OverlappingInlineFormSet)

Models

class BinsHistory(models.Model):

    class DocumentTypes(models.IntegerChoices):
        BIRTH = 1
        DEATH = 2
        FETAL_DEATH = 3

    class DocumentStatus(models.IntegerChoices):
        EMPTY = 1
        FILLED = 2
        VOID = 3

    document_type = models.IntegerField(
        choices=DocumentTypes.choices)
    document_status = models.IntegerField(
        choices=DocumentStatus.choices,
        default=DocumentStatus.EMPTY)
    serial_range = IntegerRangeField()
    series = models.PositiveIntegerField()
    created_at = models.DateTimeField(auto_now_add=True)
    parent_transaction = models.ForeignKey(
        Transactions,
        on_delete=models.PROTECT)

    class Meta:
        verbose_name = _('Bin history')
        verbose_name_plural = _('Bins history')
        constraints = [
            ExclusionConstraint(
                name='bin_history_ranges_overlap',
                expressions=[
                    (models.F('parent_transaction'), RangeOperators.EQUAL),
                    (models.F('document_type'), RangeOperators.EQUAL),
                    ('series', RangeOperators.EQUAL),
                    ('serial_range', RangeOperators.OVERLAPS),
                ])]

    def clean(self):
        parent_transaction = self.parent_transaction
        document_type = self.document_type
        series = self.series
        lower = self.serial_range.lower
        upper = self.serial_range.upper
        serial_range = NumericRange(
            lower, upper, '[]')
        if (
            BinsHistory.objects.filter(
                Q(parent_transaction=parent_transaction) &
                Q(document_type=document_type) &
                Q(series=series) &
                Q(serial_range__overlap=serial_range))
                .exists()
        ):
            raise ValidationError(
                _('Range %(range)s already exist for '
                  'the series %(series)s of the type '
                  '%(document)s.'),
                params={'series': series,
                        'range': serial_range,
                        'document': document_type},
                code='overlapping_history_ranges',
            )

    @property
    def get_bin_size(self):
        lower = self.serial_range.lower
        upper = self.serial_range.upper
        return 1 + upper - lower

    @property
    def get_bin_range(self):
        lower = self.serial_range.lower
        upper = self.serial_range.upper
        return f'{lower}-{upper}'

    def delete(self, *args, **kwargs):
        return ProtectedError(_('Can''t delete history'), self)

    def save(self, *args, **kwargs):
        if self.serial_range.upper_inc:
            super().save(*args, **kwargs)
            return

        lower = self.serial_range.lower
        upper = self.serial_range.upper
        self.serial_range = NumericRange(
            lower, upper, '[]')
        super().save(*args, **kwargs)

class Bins(models.Model):

    class DocumentTypes(models.IntegerChoices):
        BIRTH = 1
        DEATH = 2
        FETAL_DEATH = 3

    class DocumentStatus(models.IntegerChoices):
        EMPTY = 1
        FILLED = 2
        VOID = 3

    document_type = models.IntegerField(
        choices=DocumentTypes.choices)
    document_status = models.IntegerField(
        choices=DocumentStatus.choices,
        default=DocumentStatus.EMPTY)
    created_at = models.DateTimeField(auto_now_add=True)
    series = models.PositiveIntegerField()
    serial_range = IntegerRangeField()
    located_at = models.ForeignKey(
        Warehouse,
        on_delete=models.PROTECT)
    parent_transaction = models.ForeignKey(
        Transactions,
        on_delete=models.PROTECT)

    objects = BinsManager()

    def close_bounds(unbound_range):
        lower_bound = unbound_range.lower
        upper_bound = unbound_range.upper
        return NumericRange(
            lower_bound, upper_bound, '[]')

    def clean(self):
        document_type = self.document_type
        series = self.series
        lower = self.serial_range.lower
        upper = self.serial_range.upper
        serial_range = NumericRange(
            lower, upper, '[]')
        if (
            Bins.objects.filter(
                Q(document_type=document_type) &
                Q(series=series) &
                Q(serial_range__overlap=serial_range))
                .exists()
        ):
            raise ValidationError(
                _('Range %(range)s already exist for '
                  'the series %(series)s of the type '
                  '%(document)s.'),
                params={'series': series,
                        'range': serial_range,
                        'document': document_type},
                code='overlapping_ranges',
            )

    def save(self, *args, **kwargs):
        if self.serial_range.upper_inc:
            super().save(*args, **kwargs)
            return

        lower = self.serial_range.lower
        upper = self.serial_range.upper
        self.serial_range = NumericRange(
            lower, upper, '[]')
        super().save(*args, **kwargs)

    class Meta:
        verbose_name = _('Bin')
        verbose_name_plural = _('Bins')
        constraints = [
            ExclusionConstraint(
                name='bin_ranges_overlap',
                expressions=[
                    (models.F('document_type'), RangeOperators.EQUAL),
                    ('series', RangeOperators.EQUAL),
                    ('serial_range', RangeOperators.OVERLAPS),
                ],
            ),
        ]

Thanks. I’m going to recreate this in one of my test environments to see what I get.

Ahh, it just sunk in that you’re using an InlineFormset here - that needs to be created with the instance of the base model to which the individual entries are related. (See Inline Formsets)

I’m fairly certain that the Transactions instance that the Bins are going to relate to need to be saved first in this circumstance and then use that instance to create the instance of the InlineFormset objects.

Or, if you really want to process these “together”, you could probably replace the InlineFormset with a ModelFormset, and set that field specifically as you are processing that form.

Side note: In addition to printing the errors for the formset, you may also want to print the errors for each of the individual forms.

But I’m saving the transaction instance and setting it up before hand.

if transaction_form.is_valid():
    transaction_instance = transaction_form.save(commit=False)
    ....
    transaction_instance.save()

bins = bins_formset.save(commit=False)
if bins_formset.is_valid():
   '''
   This correctly returns False, because I'm unconditionally raising a ValidationError
   from my formset definition.
   '''
   for bin_instance in bins:
         bin_instance.parent_transaction = transaction_instance
         bin_instance.save()

This above correctly processes the forms and saves all the models. It’s wrong? Why it works well except for ValidationErrors?

If I’m set on using inlineformsets do I really have to do something like:

transaction_form = DocumentCreationForm(request.POST)
if transaction_form.is_valid():
    transaction_instance = transaction_form.save()
bins_formset = BinsFormset(request.POST,prefix='bins_set', instance=transaction_instance)
bins_history_formset = BinsHistoryFormset(request.POST,prefix='bins_set', instance=transaction_instance)
...

That would make keeping the entire operation atomic very convoluted, since now instead of creating all my objects upfront, I have to move request object into the atomic operation since I need to save the Transaction model before I could create the Bins/BinsHistory instances. Could I make something like:

transaction_form = DocumentCreationForm(request.POST)
bins_formset = BinsFormset(request.POST,prefix='bins_set')
bins_history_formset = BinsHistoryFormset(request.POST,prefix='bins_set')
with transaction.atomic:
    if transaction_form.is_valid():
        transaction_instance = transaction_form.save(commit=False)
    transaction_instance.save()
    bins_formset(instance=transaction_instance)
    if bins_formset.is_valid():
        bins_formset.save()

But what I can’t comprehend yet is how would that make the ValidationError being properly populated into formset.errors? I’m not understanding that. That’s very unintuitive because they don’t seem to care what I’m doing with them, like presenting two forms with relations between them, but not declaring them outright, since Django seems to process them happily. Shouldn’t be instance a required parameter to pass when I’m initializing?

No, you are not.

    def post(self, request):
        transaction_form = DocumentCreationForm(request.POST)
        bins_formset = BinsFormset(
            request.POST,
            prefix='bins_set')
        bins_history_formset = BinsHistoryFormset(
            request.POST,
            prefix='bins_set')
        self.handle_forms(
            transaction_form,
            bins_formset,
            bins_history_formset)

You are creating the formsets before you call handle_forms.

Not necessarily. The creation of the forms within a formset is a “lazy” process. Those forms don’t exist until the first time they’re needed, allowing for that type of information to be supplied after the definition but before usage.

You are only going to get the understanding that you’re looking for by digging into exactly what’s happening here. There’s a point at which, when you’re looking for a degree of understanding of details that aren’t covered within the docs, there really isn’t anything better than going to the source to see how it’s all put together.

Spend the time in the source code of these functions, and run your code under more controlled and observable conditions. That will help provide the clarity you seek.

This seems to be a confusion about the difference between how forms vs formset behave. Formsets before 4.0 will not show their error in the rendered template, that changed after 4.0. So, while I can find form.errors, formset errors are simply not there. The only confusion I have is why errors is populated with two empty objects, but the documentation seems to hint that this is normal and expected.

For future reference, this is how it looks like, to check that the formset isn’t inserting the same data:

class CheckOverlappingInlineFormSet(forms.BaseInlineFormSet):
    def clean(self):
        super().clean()
        if any(self.errors):
            return

        for form_a, form_b in combinations(self.forms, 2):
            form_a_dt = form_a.cleaned_data['document_type']
            form_a_s = form_a.cleaned_data['series']
            form_a_sr = form_a.cleaned_data['serial_range']
            form_a_set = set(range(form_a_sr.lower, form_a_sr.upper))

            form_b_dt = form_b.cleaned_data['document_type']
            form_b_s = form_b.cleaned_data['series']
            form_b_sr = form_b.cleaned_data['serial_range']
            form_b_set = set(range(form_b_sr.lower, form_b_sr.upper))

            if form_a_dt != form_b_dt:
                return
            if form_a_s != form_b_s:
                return
            if not form_a_set.isdisjoint(form_b_set):
                return
            raise forms.ValidationError(
                    _('Range %(form_a_range)s already exist for '
                      'the series %(form_a_series)s of the type '
                      '%(form_a_document)s is already used for '
                      'range %(form_b_range)s.'),
                    params={'form_a_series': form_a_s,
                            'form_a_range': form_a_sr,
                            'form_a_document': form_a_dt,
                            'form_b_series': form_b_s,
                            'form_b_range': form_a_sr,
                            'form_b_document': form_b_dt},
                    code='overlapping_form_ranges',
            )

BinsFormset = forms.inlineformset_factory(
    Transactions, Bins, form=BinsForm,
    extra=6, can_delete=False, max_num=12,
    formset=CheckOverlappingInlineFormSet)

BinsHistoryFormset = forms.inlineformset_factory(
    Transactions, BinsHistory, form=BinHistoryForm,
    extra=60, can_delete=False, max_num=60,
    formset=CheckOverlappingInlineFormSet)

In your model to verify that it doesn’t exist already on your database:

def clean(self):
    document_type = self.document_type
    series = self.series
    lower = self.serial_range.lower
    upper = self.serial_range.upper
    serial_range = NumericRange(
        lower, upper, '[]')
    if (
        Bins.objects.filter(
            Q(document_type=document_type) &
            Q(series=series) &
            Q(serial_range__overlap=serial_range))
            .exists()
    ):
        raise ValidationError(
            _('Range %(range)s already exist for '
              'the series %(series)s of the type '
              '%(document)s.'),
            params={'series': series,
                    'range': serial_range,
                    'document': document_type},
            code='overlapping_ranges',
    )

The ValidationError message from your model is automatically added to the form, for the formset you can access it from your template if you use Django pre-4.0:

<form action="{% url 'manager:document_creation' %}"
      method="post">
  {% csrf_token %}
  {{ transaction_form }}
  <table>
    {{ bins_formset.non_form_errors }}
    {{ bins_formset.as_table }}
    </table>
  <input type="submit" value="Crear">
</form>

You have to pass the same object you applied formset.is_valid() in your view render context:

def post(self, request):
    transaction_form = DocumentCreationForm(request.POST)
    bins_formset = BinsFormset(
        request.POST,
        prefix='bins_set')

    if transaction_form.is_valid() and bins_formset.is_valid() and bins_history_formset.is_valid():
        #handle valid data

    context = {
        'transaction_form': transaction_form,
        'bins_formset': bins_formset, # This can't be missing.
    }
    return render(
        request, self.template_name, context)
1 Like