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

2

10

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>
...
Haggadah answered 18/3, 2018 at 16:9 Comment(7)
What are you actually trying to achieve in the save method of ItemForm? Why do you add the field description? It belongs to Code. You should only attach an item (or items) to the code.Wivestad
It was like this before moving from a simple form in a "item detail page" to a formset. It allowed me to save a single item in its page. Now I want to change it to be able to save all the fields in the formsetHaggadah
The method save of ItemForm makes no sense to me. First you call the parent method with item = super().save(commit=False) but you don't return item. Assigning values to item.box_one and item.box_two goes with the wind. At the end you're just trying to save an instance of the related Code model.Wivestad
Can you include the code for your post method in your view that handles the submitted formset? Also please include any form errors on the forms, i.e. formset.errors from your post method. As alluded to by cezar, you're not doing anything useful in your save method, get rid of it altogether.Lightening
I know, that method was working before I move from a simple form to a formset as I said before. I don't have any post method implemented, because I don't know where or how I should do it. I'm sorry, but I never worked with formset before, so any help with this will be perfect.Haggadah
@Lightening Every save implementation I see for formsets uses a request parameter and then if request.method == POST... but in a class-based views, I don't know where to implement or add thatHaggadah
@AbelPaz please take a look at the docs on handling forms with class-based views. I'll update my answer regarding this.Lightening
L
4

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.

Lightening answered 27/3, 2018 at 12:8 Comment(0)
W
3

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.

Wivestad answered 21/3, 2018 at 21:44 Comment(7)
Mmmm... and how do you use the property from the formset at item_list.html?Haggadah
@AbelPaz Please check the edit. The ModelForm holds the model instance (object) and can therefore access all of its properties. I hope will bring you to the right path.Wivestad
Ok, I'll try it. And what about the other issue? How to save all the form data when clicking the Update button? My save method is not working at all.Haggadah
This is a good solution to OP's problem. But one should note that one cannot query objects using a property. For example Item.objects.filter(amount=x) is not possible. They should be thought of as usable for 'row-level' operations only.Lightening
@Lightening Thanks! That's a good point. Here is a workaround. Let's say you want to retrieve all objects with amount less than 10: pk_list = [item.pk for item in Item.objects.all() if item.amount < 10]; qs = Item.objects.filter(pk__in=pk_list).Wivestad
@Wivestad that works, but results in you querying for and iterating over every single row in the table, then querying it a second time to filter it. For a table of any significant size, you take a serious performance hit. It's no different really than if you did not have the property at all. That's why they should be thought of as useful for 'row-level' operations only. But it does fit well for OP's case, since I don't think OP needs to query on the property.Lightening
Thanks @Lightening & cezar. I'm still trying to figure out how to save the whole formset when clicking the "Update" button, but I can't make it work. Any idea?Haggadah

© 2022 - 2024 — McMap. All rights reserved.