Custom django inlineformset validation based on user permissions

175 Views Asked by At

The objective is to have a simple workflow where an order and associated orderlines (created in a previous step) needs to be approved by the relevant budget holder. The approval form shows all order lines but disables those lines that the current user is not associated with (they should be able to see the overall order but only be able to edit lines that they are permitted to). They should be able to add new lines if necessary. The user needs to decide whether to approve or not (approval radio cannot be blank)

enter image description here

The initial form presents correctly and is able to save inputs correctly when all values are inputted correctly - however, if it fails validation then the incorrect fields get highlighted and their values are cleared.

enter image description here

models.py

class Order(models.Model):
    department = models.ForeignKey(user_models.Department, on_delete=models.CASCADE)
    location = models.ForeignKey(location_models.Location, on_delete=models.CASCADE, null=True)
    description = models.CharField(max_length=30)
    project = models.ForeignKey(project_models.Project, on_delete=models.CASCADE)
    product = models.ManyToManyField(catalogue_models.Product, through='OrderLine', related_name='orderlines')
    total = models.DecimalField(max_digits=20, decimal_places=2, null=True, blank=True)

    def __str__(self):
        return self.description

class OrderLine(models.Model):
    order = models.ForeignKey(Order, on_delete=models.CASCADE)
    project_line = models.ForeignKey(project_models.ProjectLine, on_delete=models.SET_NULL, null=True, blank=False)
    product = models.ForeignKey(catalogue_models.Product, on_delete=models.CASCADE)
    quantity = models.PositiveIntegerField()
    price = models.DecimalField(max_digits=20, decimal_places=4)
    total = models.DecimalField(max_digits=20, decimal_places=2)
    budgetholder_approved = models.BooleanField(null=True)

    def get_line_total(self):
        total = self.quantity * self.price
        return total

    def save(self, *args, **kwargs):
        self.total = self.get_line_total()
        super(OrderLine, self).save(*args, **kwargs)

    def __str__(self):
        return self.product.name

views.py

class BudgetApprovalView(FlowMixin, generic.UpdateView):
    form_class = forms.BudgetHolderApproval

    def get_object(self):
        return self.activation.process.order

    def get_context_data(self, **kwargs):
        data = super(BudgetApprovalView, self).get_context_data(**kwargs)

        if self.request.POST:
            data['formset'] = forms.OrderLineFormet(self.request.POST, instance=self.object)
        else:
            data['formset'] = forms.OrderLineFormet(instance=self.activation.process.order, form_kwargs={'user': self.request.user})
        return data


    def post(self, request, *args, **kwargs):

        self.object = None
        form_class = self.get_form_class()
        form = self.get_form(form_class)
        form = forms.BudgetHolderApproval(self.request.POST, instance=self.activation.process.order)
        formset = forms.OrderLineFormet(self.request.POST, instance=self.activation.process.order)

        if form.is_valid() and formset.is_valid():
            return self.is_valid(form, formset)

        else:
            return self.is_invalid(form, formset)

    def is_valid(self, form, formset):

        self.object = form.save(commit=False)
        self.object.created_by = self.request.user
        self.activation.process.order = self.object


        with transaction.atomic():
            self.object.save()
            self.activation.done()
            formset.save()

        return HttpResponseRedirect(self.get_success_url())

    def is_invalid(self, form, formset):

        return self.render_to_response(self.get_context_data(form=form, formset=formset))

I have tried a couple of things to figure this out - without success:

  1. to override the clean() method of the ModelForm - however, I cannot figure out how to determine if the submitted form is disabled or not.

forms.py

class OrderForm(forms.ModelForm):
    class Meta:
        model = models.Order
        fields = ['description', 'project', 'location']

    def __init__(self, *args, **kwargs):
        super(OrderForm, self).__init__(*args, **kwargs)

        self.helper = FormHelper()
        self.helper.form_tag = False


class OrderLine(forms.ModelForm):
    class Meta:
        model = models.OrderLine
        exclude = ['viewflow']

    def __init__(self, *args, **kwargs):

        YES_OR_NO = (
            (True, 'Yes'),
            (False, 'No')
        )

        self.user = kwargs.pop('user', None)

        super(OrderLine, self).__init__(*args, **kwargs)

        self.fields['project_line'].queryset = project_models.ProjectLine.objects.none()
        self.fields['budgetholder_approved'].widget = forms.RadioSelect(choices=YES_OR_NO)

        if self.instance.pk:
            self.fields['budgetholder_approved'].required = True
            self.fields['order'].disabled = True
            self.fields['project_line'].disabled = True
            self.fields['product'].disabled = True
            self.fields['quantity'].disabled = True
            self.fields['price'].disabled = True
            self.fields['total'].disabled = True
            self.fields['budgetholder_approved'].disabled = True

        if 'project' in self.data:
            try:
                project_id = int(self.data.get('project'))
                self.fields['project_line'].queryset = project_models.ProjectLine.objects.filter(project_id=project_id)
            except (ValueError, TypeError):
                pass
        elif self.instance.pk:
            self.fields['project_line'].queryset = self.instance.order.project.projectline_set
            project_line_id = int(self.instance.project_line.budget_holder.id)
            user_id = int(self.user.id)

            if project_line_id == user_id:
                self.fields['budgetholder_approved'].disabled = False


        self.helper = FormHelper()
        self.helper.template = 'crispy_forms/templates/bootstrap4/table_inline_formset.html'
        self.helper.form_tag = False

    def clean(self):

        super(OrderLine, self).clean()

        pprint(vars(self.instance))
        
        //This just returns a list of fields without any attributes to apply the validation logic


OrderLineFormet = forms.inlineformset_factory(
    parent_model=models.Order,
    model=models.OrderLine,
    form=OrderLine,
    extra=2,
    min_num=1
)
  1. to override the clean() method of the BaseInlineFormSet - however, I cannot disable the fields in the init or any of the validation rules (it silently fails validation and presents a blank inlineformset on failure - it never gets to clean() method.

forms.py

class OrderForm(forms.ModelForm):
    class Meta:
        model = models.Order
        fields = ['description', 'project', 'location']

    def __init__(self, *args, **kwargs):
        super(TestOrderForm, self).__init__(*args, **kwargs)

        self.helper = FormHelper()
        self.helper.form_tag = False


class BaseTestOrderLine(forms.BaseInlineFormSet):
    def __init__(self, user, *args, **kwargs):
        self.user = user

        super(BaseTestOrderLine, self).__init__(*args, **kwargs)

        self.helper = FormHelper()
        self.helper.template = 'crispy_forms/templates/bootstrap4/table_inline_formset.html'
        self.helper.form_tag = False
        
    // Never gets to the clean method as is_valid fails silently

    def clean(self):
        super(BaseTestOrderLine, self).clean()

        if any(self.errors):

            pprint(vars(self.errors))

            return
            
OrderLineFormet = forms.inlineformset_factory(
    parent_model=models.Order,
    model=models.OrderLine,
    formset=BaseTestOrderLine,
    exclude=['order'],
    extra=2,
    min_num=1
)

Edit - reflecting progress based on Dao's suggestion (the form reloads correctly with the validation errors showing correctly)

The only remaining problem is that when the form reloads - the field (budgetholder_approved) that should still be enabled is disabled. One of the two approval checkbox lines lines should be editable

enter image description here

1

There are 1 best solutions below

2
Đào Minh Hạt On

Looks like because you have different formset context data on submit invalid

        if self.request.POST:
            data['formset'] = forms.OrderLineFormet(self.request.POST, instance=self.activation.process.order, form_kwargs={'user': self.request.user})
        else:
            data['formset'] = forms.OrderLineFormet(instance=self.activation.process.order, form_kwargs={'user': self.request.user})
        return data

Edit for updated Q: Didn't test this out because of time constraint but because you already initiated the field and overwrite the widget, so if you need to update disabled attr of widget instead of field.

self.fields['budgetholder_approved'].widget = forms.RadioSelect(choices=YES_OR_NO)
self.fields['budgetholder_approved'].widget.attrs['disabled'] = False