Multiple models in Django form

Question:

I have the following models:

class Category(models.Model):
    label = models.CharField(max_length=40)
    description = models.TextField()


class Rating(models.Model):
    review = models.ForeignKey(Review, on_delete=models.CASCADE)
    category = models.ForeignKey(Category, on_delete=models.CASCADE)
    rating = models.SmallIntegerField()


class Review(models.Model):
    author = models.ForeignKey(User, related_name="%(class)s_author", on_delete=models.CASCADE)
    coach = models.ForeignKey(User, related_name="%(class)s_coach", on_delete=models.CASCADE)
    comments = models.TextField()

I’d like to create a front-end form which allows a user to review a coach, including a rating for some pre-populated categories.

In my head, the form would look something like:

 Coach: _______________         # Selection of all coach users from DB, this works as standard

 Category: "Professionalism"    # These would be DB entries from the Category model
 Rating: _ / 5

 Category: "Friendliness"
 Rating: _ / 5

 Category: "Value"
 Rating: _ / 5

 Comments:
 _________________________________
 _________________________________

 Submit

I’ve seen Django Formsets in the documentation but these appear to exist for creating multiple forms from the same model as a single form?

Not looking for a full answer, but if someone could point me in the right direction, it’d be hugely appreciated.

EDIT: Vineet’s answer (https://stackoverflow.com/a/65883875/864245) is almost exactly what I’m looking for, but it’s for the Admin area, where I need it on the front-end.

Asked By: turbonerd

||

Answers:

Use this in your app’s admin.py file

from django.contrib import admin

from .models import Review, Rating, Category

class RatingInline(admin.TabularInline):
    model = Rating
    fieldsets = [
        ('XYZ', {'fields': ('category', 'rating',)})
    ]
    extra = 0
    readonly_fields = ('category',)
    show_change_link = True

    def has_change_permission(self, request, obj=None):
        return True


class ReviewAdmin(admin.ModelAdmin):
    fields = ('author', 'coach', 'comments')
    inlines = [RatingInline]


admin.site.register(Review, ReviewAdmin)
admin.site.register(Rating)
admin.site.register(Category)

You admin page will look like this:
enter image description here

Answered By: Vineet Yadav

Given that the categories are fairly static, you don’t want your users to select the categories. The categories themselves should be labels, not fields for your users to select.

You mention in the comment, that the labels will sometimes change. I think there are two questions I would ask before deciding how to proceed here:

  1. Who will update the labels moving forwards (do they have basic coding ability, or are they reliant on using something like the admin).
  2. When the labels change, will their fundamental meaning change or will it just be phrasing

Consideration 1

If the person changing the labels has a basic grasp of Django, and the appropriate permissions (or can ask a dev to make the changes for them) then just hard-coding these 5 things is probably the best way forward at first:

class Review(models.Model):
    author = models.ForeignKey(User, related_name="%(class)s_author", on_delete=models.CASCADE)
    coach = models.ForeignKey(User, related_name="%(class)s_coach", on_delete=models.CASCADE)
    comments = models.TextField()
    # Categories go here...
    damage = models.SmallIntegerField(
        help_text="description can go here", 
        verbose_name="label goes here"
    )
    style = models.SmallIntegerField()
    control = models.SmallIntegerField()
    aggression = models.SmallIntegerField()

This has loads of advantages:

  • It’s one very simple table that is easy to understand, instead of 3 tables with joins.
  • This will make everything up and down your code-base simpler. It’ll make the current situation (managing forms) easier, but it will also make every query, view, template, report, management command, etc. you write easier, moving forwards.
  • You can edit the labels and descriptions as and when needed with verbose_name and help_text.

If changing the code like this isn’t an option though, and the labels have to be set via something like the Django admin-app, then a foreign-key is your only way forward.

Again, you don’t really want your users to choose the categories, so I would just dynamically add them as fields, rather than using a formset:

class Category(models.Model):
    # the field name will need to be a valid field-name, no space etc.
    field_name = models.CharField(max_length=40, unique=True)
    label = models.CharField(max_length=40)
    description = models.TextField()
class ReviewForm.forms(forms.Form):
    coach = forms.ModelChoiceField()

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

        # Here we dynamically add the category fields
        categories = Categories.objects.filter(id__in=[1,2,3,4,5])
        for category in categories:
            self.fields[category.field_name] = forms.IntegerField(
                help_text=category.description,
                label=category.label,
                required=True,
                min_value=1,
                max_value=5
            )

        self.fields['comment'] = forms.CharField(widget=forms.Textarea)

        return return_value

Since (I’m assuming) the current user will be the review.author, you are going to need access to request.user and so we should save all your new objects in the view rather than in the form. Your view:

def add_review(request):
    if request.method == "POST":
        review_form = ReviewForm(request.POST)
        if review_form.is_valid():
            data = review_form.cleaned_data
            # Save the review
            review = Review.objects.create(
                author=request.user,
                coach=data['coach']
                comment=data['comment']
            )
            # Save the ratings
            for category in Category.objects.filter(id__in=[1,2,3,4,5]):
                Rating.objects.create(
                    review=review
                    category=category
                    rating=data[category.field_name]
                )

            # potentially return to a confirmation view at this point
    if request.method == "GET":
        review_form = ReviewForm()

    return render(
        request, 
        "add_review.html", 
        {
             "review_form": review_form
        }
    )

Consideration 2

To see why point 2 (above) is important, imagine the following:

  1. You start off with 4 categories: Damage, Style, Control and Agression.
  2. Your site goes live and some reviews come in. Say Coach Tim McCurrach gets a review with scores of 2,1,3,5 respectively.
  3. Then a few months down the line we realise ‘style’ isn’t a very useful category, so we change the label to ‘effectiveness’.
  4. Now Tim McCurrach has a rating of ‘1’ saved against a category that used to have label ‘style’ but now has label ‘effectiveness’ which isn’t what the author of the review meant at all.
  5. All of your old data is meaningless.

If Style is only ever going to change to things very similar to style we don’t need to worry so much about that.

If you do need to change the fundamental nature of labels, I would add an active field to your Category model:

class Category(models.Model):
    field_name = models.CharField(max_length=40, unique=True)
    label = models.CharField(max_length=40)
    description = models.TextField()
    active = models.BooleanField()

Then in the code above, instead of Category.objects.filter(id__in=[1,2,3,4,5]) I would write, Category.objects.filter(active=True). To be honest, I think I would do this either way. Hard-coding ids in your code is bad-practice, and very liable to going wrong. This second method is more flexible anyway.

Answered By: tim-mccurrach

You could "embed" an inline formset into your review form. Then you can call form.save() to save the review and all the associated ratings in one go. Here is a working example:

# forms.py
from django import forms
from . import models

class ReviewForm(forms.ModelForm):
    class Meta:
        model = models.Review
        fields = '__all__'

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.ratings = forms.inlineformset_factory(
            parent_model=models.Review,
            model=models.Rating,
            extra=5,
            min_num=5,
        )(
            data=self.data if self.is_bound else None,
            files=self.files if self.is_bound else None,
            instance=self.instance,
        )

    def is_valid(self):
        return super().is_valid() and self.ratings.is_valid() # AND

    def has_changed(self):
        return super().has_changed() or self.ratings.has_changed() # OR

    def save(self):
        review = super().save()
        self.ratings.save()
        return review

As you can see, the __init__() method sets the attribute self.ratings which you can later recall in your template like this:

<form method="post">
  {% csrf_token %}

  <div class="review">
    {{ form.as_p }}
  </div>

  <div class="ratings">
    {{ form.ratings.management_form }}
    {% for rating_form in form.ratings %}
      <div class="single_rating">
        {{ rating_form.as_p }}
      </div>
    {% endfor %}
  </div>

  <button>Save</button>
</form>

Finally, here’s how your views.py might look like (using Django’s class-based views):

from django.views import generic
from . import models
from . import forms

class ReviewView(generic.UpdateView):
    model = models.Review
    form_class = forms.ReviewForm
Answered By: Jaap Joris Vens
Categories: questions Tags: ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.