Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Saving class-based view formset items with a new "virtual" column

I have a table inside a form, generated by a formset.

In this case, my problem is to save all the items after one of them is modified, adding a new "virtual" column as the sum of other two (that is only generated when displaying the table, not saved). I tried different ways, but no one is working.

Issues:

  • This save is not working at all. It worked when it was only one form, but not for the formset
  • I tried to generate the column amount as a Sum of box_one and box_two without success. I tried generating the form this way too, but this is not working:
formset = modelformset_factory(
    Item, form=ItemForm)(queryset=Item.objects.order_by(
        'code__name').annotate(amount=Sum('box_one') + Sum('box_two')))

This issue is related to this previous one, but this new one is simpler: Pre-populate HTML form table from database using Django

Previous related issues at StackOverflow are very old and not working for me.

I'm using Django 2.0.2

Any help would be appreciated. Thanks in advance.

Current code:

models.py

class Code(models.Model):
    name = models.CharField(max_length=6)
    description = models.CharField(max_length=100)

    def __str__(self):
        return self.name

class Item(models.Model):
    code = models.ForeignKey(Code, on_delete=models.DO_NOTHING)
    box_one = models.IntegerField(default=0)
    box_two = models.IntegerField(default=0)

    class Meta:
        ordering = ["code"]

views.py

class ItemForm(ModelForm):
    description = CharField()

    class Meta:
        model = Item
        fields = ['code', 'box_one', 'box_two']

    def save(self, commit=True):
        item = super(ItemForm, self).save(commit=commit)
        item.box_one = self.cleaned_data['box_one']
        item.box_two = self.cleaned_data['box_two']
        item.code.save()

    def get_initial_for_field(self, field, field_name):
        if field_name == 'description' and hasattr(self.instance, 'code'):
            return self.instance.code.description
        else:
            return super(ItemForm, self).get_initial_for_field(
                field, field_name)


class ItemListView(ListView):
    model = Item

    def get_context_data(self, **kwargs):
        data = super(ItemListView, self).get_context_data()
        formset = modelformset_factory(Item, form=ItemForm)()
        data['formset'] = formset
        return data

urls.py

app_name = 'inventory'
urlpatterns = [
    path('', views.ItemListView.as_view(), name='index'),

item_list.html

...
          <div>
            <form action="" method="post"></form>
            <table>
                {% csrf_token %}
                {{ formset.management_form }}
                {% for form in formset %}
                    <thead>
                        <tr>
                        {% if forloop.first %}
                            <th>{{ form.code.label_tag }}  </th>
                            <th>{{ form.description.label_tag }}  </th>
                            <th> <label>Amount:</label> </th>
                            <th>{{ form.box_one.label_tag }}  </th>
                            <th>{{ form.box_two.label_tag }}  </th>
                        {% endif %}
                        </tr>
                    </thead>
                    <tbody>
                        <tr>
                            <td>{{ form.code }}</td>
                            <td>{{ form.description }}</td>
                            <td>{{ form.amount }}</td>
                            <td>{{ form.box_one }}</td>
                            <td>{{ form.box_two }}</td>
                        </tr>
                    </tbody>

                {% endfor %}

                <input type="submit" value="Update" />
            </table>
            </form>
          </div>
...
like image 255
Abel Paz Avatar asked Mar 18 '18 16:03

Abel Paz


2 Answers

Annotating query with virtual column

Sum is an aggregate expression and is not how you want to be annotating this query in this case. Instead, you should use an F exrepssion to add the value of two numeric fields

qs.annotate(virtual_col=F('field_one') + F('field_two'))

So your corrected queryset would be

Item.objects.order_by('code__name').annotate(amount=F('box_one') + F('box_two'))

The answer provided by cezar works great if intend to use the property only for 'row-level' operations. However, if you intend to make a query based on amount, you need to annotate the query.

Saving the formset

You have not provided a post method in your view class. You'll need to provide one yourself since you're not inheriting from a generic view that provides one for you. See the docs on Handling forms with class-based views. You should also consider inheriting from a generic view that handles forms. For example ListView does not implement a post method, but FormView does.

Note that your template is also not rendering form errors. Since you're rendering the formset manually, you should consider adding the field errors (e.g. {{ form.field.errors}}) so problems with validation will be presented in the HTML. See the docs on rendering fields manually.

Additionally, you can log/print the errors in your post method. For example:

def post(self, request, *args, **kwargs):
    formset = MyFormSet(request.POST)
    if formset.is_valid():
        formset.save()
        return SomeResponse
    else:
        print(formset.errors)
        return super().post(request, *args, **kwargs)

Then if the form does not validate you should see the errors in your console/logs.

like image 96
sytech Avatar answered Nov 20 '22 13:11

sytech


You're already on the right path. So you say you need a virtual column. You could define a virtual property in your model class, which won't be stored in the database table, nevertheless it will be accessible as any other property of the model class.

This is the code you should add to your model class Item:

class Item(models.Model):
    # existing code

    @property
    def amount(self):
        return self.box_one + self.box_one

Now you could do something like:

item = Item.objects.get(pk=1)
print(item.box_one) # return for example 1
print(item.box_two) # return for example 2
print(item.amount) # it will return 3 (1 + 2 = 3)

EDIT:
Through the ModelForm we have access to the model instance and thus to all of its properties. When rendering a model form in a template we can access the properties like this:

{{ form.instance.amount }}

The idea behind the virtual property amount is to place the business logic in the model and follow the approach fat models - thin controllers. The amount as sum of box_one and box_two can be thus reused in different places without code duplication.

like image 3
cezar Avatar answered Nov 20 '22 12:11

cezar