62

I would like to make an entire inline formset within an admin change form compulsory. So in my current scenario when I hit save on an Invoice form (in Admin) the inline Order form is blank. I'd like to stop people creating invoices with no orders associated.

Anyone know an easy way to do that?

Normal validation like (required=True) on the model field doesn't appear to work in this instance.

juliomalegria
  • 24,229
  • 14
  • 73
  • 89
user108791
  • 703
  • 1
  • 6
  • 10

6 Answers6

92

The best way to do this is to define a custom formset, with a clean method that validates that at least one invoice order exists.

class InvoiceOrderInlineFormset(forms.models.BaseInlineFormSet):
    def clean(self):
        # get forms that actually have valid data
        count = 0
        for form in self.forms:
            try:
                if form.cleaned_data:
                    count += 1
            except AttributeError:
                # annoyingly, if a subform is invalid Django explicity raises
                # an AttributeError for cleaned_data
                pass
        if count < 1:
            raise forms.ValidationError('You must have at least one order')

class InvoiceOrderInline(admin.StackedInline):
    formset = InvoiceOrderInlineFormset


class InvoiceAdmin(admin.ModelAdmin):
    inlines = [InvoiceOrderInline]
Daniel Roseman
  • 588,541
  • 66
  • 880
  • 895
  • 3
    I found that if the delete box is checked, it's possible to validate with 0 orders. See my answer for a revised class that solves that problem. – Dan Breen Dec 10 '09 at 23:13
  • Thank you so much for this fix (and Dan for the enhancement). As a possible hint to others I've made a 'class MandatoryInlineFormSet(BaseInlineFormSet)' and then derived InvoiceAdminFormSet from that. In my InvoiceAdminFormSet I have a clean() method that does custom validation but first calls back to MandatoryInlineFromSet.clean(). – Kurt Jun 03 '10 at 15:19
  • 1
    Worked for me also when deleting : Replace ------ if form.cleaned_data: ------ with ------ if form.cleaned_data and not form.cleaned_data.get('DELETE', False): – vinyll Jun 11 '12 at 09:35
  • 4
    You may want to call the parent class's clean method at the beginning or end: `super(InvoiceOrderInlineFormset, self).clean()` – nofinator Mar 22 '13 at 18:13
  • I am trying to use `hasattr()` function instead of `try: except ` above but the code is misbehaving. Its counting all forms!! Can you please tell me why this is happening – Surya May 15 '14 at 17:11
  • To calculate count you can use just `count = sum([bool(f.cleaned_data) for f in self.forms])` It works in Django 1.10 – Mark Mishyn Jan 04 '17 at 11:36
22

Daniel's answer is excellent and it worked for me on one project, but then I realized due to the way Django forms work, if you are using can_delete and check the delete box while saving, it's possible to validate w/o any orders (in this case).

I spent a while trying to figure out how to prevent that from happening. The first situation was easy - don't include the forms that are going to get deleted in the count. The second situation was trickier...if all the delete boxes are checked, then clean wasn't being called.

The code isn't exactly straightforward, unfortunately. The clean method is called from full_clean which is called when the error property is accessed. This property is not accessed when a subform is being deleted, so full_clean is never called. I'm no Django expert, so this might be a terrible way of doing it, but it seems to work.

Here's the modified class:

class InvoiceOrderInlineFormset(forms.models.BaseInlineFormSet):
    def is_valid(self):
        return super(InvoiceOrderInlineFormset, self).is_valid() and \
                    not any([bool(e) for e in self.errors])

    def clean(self):
        # get forms that actually have valid data
        count = 0
        for form in self.forms:
            try:
                if form.cleaned_data and not form.cleaned_data.get('DELETE', False):
                    count += 1
            except AttributeError:
                # annoyingly, if a subform is invalid Django explicity raises
                # an AttributeError for cleaned_data
                pass
        if count < 1:
            raise forms.ValidationError('You must have at least one order')
Dan Breen
  • 12,626
  • 4
  • 38
  • 49
5
class MandatoryInlineFormSet(BaseInlineFormSet):  

    def is_valid(self):
        return super(MandatoryInlineFormSet, self).is_valid() and \
                    not any([bool(e) for e in self.errors])  
    def clean(self):          
        # get forms that actually have valid data
        count = 0
        for form in self.forms:
            try:
                if form.cleaned_data and not form.cleaned_data.get('DELETE', False):
                    count += 1
            except AttributeError:
                # annoyingly, if a subform is invalid Django explicity raises
                # an AttributeError for cleaned_data
                pass
        if count < 1:
            raise forms.ValidationError('You must have at least one of these.')  

class MandatoryTabularInline(admin.TabularInline):  
    formset = MandatoryInlineFormSet

class MandatoryStackedInline(admin.StackedInline):  
    formset = MandatoryInlineFormSet

class CommentInlineFormSet( MandatoryInlineFormSet ):

    def clean_rating(self,form):
        """
        rating must be 0..5 by .5 increments
        """
        rating = float( form.cleaned_data['rating'] )
        if rating < 0 or rating > 5:
            raise ValidationError("rating must be between 0-5")

        if ( rating / 0.5 ) != int( rating / 0.5 ):
            raise ValidationError("rating must have .0 or .5 decimal")

    def clean( self ):

        super(CommentInlineFormSet, self).clean()

        for form in self.forms:
            self.clean_rating(form)


class CommentInline( MandatoryTabularInline ):  
    formset = CommentInlineFormSet  
    model = Comment  
    extra = 1  
Jordan Reiter
  • 20,467
  • 11
  • 95
  • 161
Kurt
  • 2,339
  • 2
  • 30
  • 31
  • Is it possible to do the same with extra = 0 ? – Siva Arunachalam Jan 01 '11 at 18:45
  • 1
    @Siva - I just checked, and yes you can have extra=0. However if you want a comment (in my case) to be mandatory then you should probably give the user a blank form or not make it mandatory. – Kurt Jan 15 '11 at 20:37
5

@Daniel Roseman solution is fine but i have some modification with some less code to do this same.

class RequiredFormSet(forms.models.BaseInlineFormSet):
      def __init__(self, *args, **kwargs):
          super(RequiredFormSet, self).__init__(*args, **kwargs)
          self.forms[0].empty_permitted = False

class InvoiceOrderInline(admin.StackedInline):
      model = InvoiceOrder
      formset = RequiredFormSet


class InvoiceAdmin(admin.ModelAdmin):
     inlines = [InvoiceOrderInline]

try this it also works :)

Ahsan
  • 11,516
  • 12
  • 52
  • 79
  • Whoops, didn't mean to upvote this. It doesn't work when the "delete" checkboxes are checked. – Tobu Nov 14 '11 at 14:52
  • didn't understand your question? this code make sure, every `Invoice` must have one `InvoiceOrder` in it. And at that time there is no delete checkboxes! – Ahsan Nov 14 '11 at 15:43
  • Wow, worked easily for me. I am using more than one inline form so I had to make this modification. `for form in self.forms: form.empty_permitted = False` Are there any better ways to do this? – cy23 May 09 '23 at 14:10
4

The situation became a little bit better but still needs some work around. Django provides validate_min and min_num attributes nowadays, and if min_num is taken from Inline during formset instantiation, validate_min can be only passed as init formset argument. So my solution looks something like this:

class MinValidatedInlineMixIn:
    validate_min = True
    def get_formset(self, *args, **kwargs):
        return super().get_formset(validate_min=self.validate_min, *args, **kwargs)

class InvoiceOrderInline(MinValidatedInlineMixIn, admin.StackedInline):
    model = InvoiceOrder
    min_num = 1
    validate_min = True

class InvoiceAdmin(admin.ModelAdmin):
    inlines = [InvoiceOrderInline]
Mihai Chelaru
  • 7,614
  • 14
  • 45
  • 51
Alexander Klimenko
  • 2,252
  • 1
  • 18
  • 20
0

Simple solution, using built-in attrb in the form class, just override the defaults

class InlineFormSet(forms.BaseInlineFormSet):

    def __init__(self, *args, **kwargs):
        super().__init__(error_class=BootStrapCssErrorList, *args, **kwargs)
        form.empty_permitted = False for form in self.forms

InvoiceItemFormSet = forms.inlineformset_factory(
    Invoice, InvoiceItem, form=InvoiceCreateItemForm, formset=InlineFormSet,
    fields=('fish_name', 'pre_bag', 'total_bags', '_total_fishes', 'price', '_sub_total'), 
    extra=0, min_num=1, can_delete=True, validate_min=True
)

This code is inline formset factory from django forms, Use validate_min=True for validate minimum row are valid or not. and add a empty_permitted=False in BaseInlineFormset from django forms.