How to use 2 models in Django CreateView with input fields for only 1 model?

I am trying to make a page like this where the quotes are randomly picked from quotes.models.Quote using classmethod Quote.get_random():

Quote1... by Author1
Quote2... by Author2

RichTextfield for user to comment on the quotes.

The user should not be able to edit the quotes in this page, just the comment. But I want the relationship between the quotes and user’s comment to be saved.

I’ve looked at previous questions and answers but those were for using multiple forms in a view and the user can edit all fields. I also looked at package django-extra-views — Django Extra Views 0.13.0 documentation but I don’t think it helps my problem.

I am stuck at displaying the quotes and passing the selected quotes to the form to be saved. Can someone help or suggest how I can make progress?

Screenshot of page:
https://i.stack.imgur.com/3AKbP.png

Using {% get_quotes %} in the post.html template, I get a list of dictionary for the quotes (as shown in screenshot). {% get_quotes 3 %} also works to generate 3 quotes.

[{'id': 81, 'text': '..., 'tags': [76, 77]}, {'id': 75, 'text': ..., 'tags': [74, 75, 76, 77, 78, 79, 80, 81]}]

But nothing happens when I try to loop through the list.

{% for quote in get_quotes %}
    {{ quote }}
{% endfor %}

quotes.models.py

from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import models
from modelcluster.fields import ParentalKey
from modelcluster.models import ClusterableModel
from modelcluster.contrib.taggit import ClusterTaggableManager
from taggit.models import TagBase, ItemBase
from wagtail.snippets.models import register_snippet

from ckeditor.fields import RichTextField
from itertools import chain

import sys

sys.path.append("..")  # Adds higher directory to python modules path.


def to_dict(instance):
    opts = instance._meta
    data = {}
    for f in chain(opts.concrete_fields, opts.private_fields):
        data[f.name] = f.value_from_object(instance)
    for f in opts.many_to_many:
        data[f.name] = [i.id for i in f.value_from_object(instance)]
    return data


class Quote(ClusterableModel):
    text = RichTextField(
        config_name="awesome_ckeditor", help_text="You must enter some quote content"
    )
    author = models.CharField(
        max_length=300, blank=True, help_text="Person who said/wrote this quote"
    )
#snipped

    @classmethod
    def get_random(cls, n=2):
        """Returns a number of random quotes."""
        import random

        n = int(n)  # Number of quotes to return. Default n=2
        last = cls.objects.count() - 1
        selection = random.sample(range(0, last), n)
        random_quotes = []
        for each in selection:
            pk = cls.objects.filter(active=True)[each].pk
            random_quotes.append(to_dict(cls.objects.get(pk=pk)))
        return random_quotes

    def __str__(self):
        """String repr of this class."""
        return f"{self.text} - {self.author}"

    class Meta:

        verbose_name = "Quote"
        verbose_name_plural = "Quotes"
        ordering = ("author", "text")

create.models.py

from userauth.models import CustomUser


class UserPost(models.Model):
    user = models.ForeignKey(CustomUser, on_delete=models.CASCADE)
    publish = models.DateField(
        default=timezone.now,
        validators=[
            MinValueValidator(
                timezone.now().date(), message="The date cannot be in the past!"
            )
        ],
    )
    created = models.DateTimeField(auto_now_add=True, editable=False)
    updated = models.DateTimeField(auto_now=True, editable=False)
    STATUS_CHOICES = (
        ("draft", "Draft"),
        ("published", "Published"),
    )
    status = models.CharField(max_length=10, choices=STATUS_CHOICES, default="draft")
    active = models.BooleanField(
        default=True, help_text="Allows moderator to unpublish posts if set to false"
    )  # Allows moderators to hide offensive posts
    inactive_message = models.TextField(
        max_length=500,
        blank=True,
        help_text="Enter the reason for setting this post to inactive",
    )
    title = models.CharField(
        max_length=300, unique_for_date="publish", help_text="You must enter a title"
    )
    slug = models.SlugField(max_length=350, unique_for_date="publish")
    content = RichTextField(
        config_name="awesome_ckeditor", help_text="You must enter some content"
    )
    quotes = ParentalManyToManyField("quotes.Quote", related_name="user_posts")

    def save(self, *args, **kwargs):
        if not self.id:
            # Newly created object, so set slug
            self.slug = slugify(self.title)
        return super(UserPost, self).save(*args, **kwargs)

    def get_absolute_url(self):
        return reverse(
            "create:detail",
            kwargs={
                "year": self.publish.year,
                "month": self.publish.month,
                "day": self.publish.day,
                "slug": self.slug,
            },
        )

templatetags.create_tags.py

from django import template

from quotes.models import Quote

register = template.Library()

@register.simple_tag
def get_quotes(n=2):
    return Quote.get_random(n)

views.py

from .models import UserPost


class OwnerMixin(object):
    """Returns only user's objects to prevent others from accessing."""

    def get_queryset(self):
        qs = super().get_queryset()
        return qs.filter(user=self.request.user)


class OwnerEditMixin(object):
    """Sets user pk"""

    def form_valid(self, form):
        form.instance.user = self.request.user
        return super().form_valid(form)

    def get_success_url(self):
        return reverse_lazy(
            "create:user_post_detail",
            kwargs={
                "year": self.object.publish.year,
                "month": self.object.publish.month,
                "day": self.object.publish.day,
                "slug": self.object.slug,
            },
        )


class UserPostCreateView(LoginRequiredMixin, OwnerEditMixin, CreateView):
    model = UserPost
    fields = ["publish", "status", "title", "content"]
    template_name = "create/post.html"

    def get_form(self):
        form = super().get_form()
        form.fields["publish"].widget = DateInput(attrs={"type": "date"})
        return form

Don’t make the quotes themselves a form. Just display them as text. (If a person can’t edit the quotes, there’s no reason to make them into a form.)

I’m not sure I understand what you’re trying to accomplish here. Are you saying you want to “randomly select 3 quotes” - and have a person comment on all three as a set? Or can they comment on each individual quote?

I want the person to comment on all three as a set. The set will be generated by randomly selecting a number of quotes.

How can I display the quotes as text but save the relationship with the user’s comment?

Is the order in which the quotes are presented important? (In other words, if you present quotes in the order of 2, 5, 8; is that the same set of quotes as if they were presented 5, 8, 2?)

Anyway, the basic idea is that you can save the list of the pks of the quotes being presented in session when the quotes are retrieved, then retrieve that list when the form is submitted.
You can also include those quote ids as a hidden form field in the form to be resubmitted with the form. However, that is vulnerable to tampering.

The order is not important. Although I presume the order will not change with the same set of quotes because of the ordering in class Meta:

Thanks for the tip on hidden form field. I would prefer not to leave anything vulnerable to tampering so I won’t use that.

I think I understand the basic idea you said but don’t know how to implement it. I’m a newbie who only started learning python a few months ago. Is there a specific part of the docs I should read or can you point out which methods I should override?

The ordering in your Meta class is not going to affect the results of your get_random method.

See How to use sessions

get_random's class Meta has ordering = ("author", "text"). Will that not work to show a set of quotes in a fixed order?

I read the docs on sessions, which led to docs on request-response. I still don’t understand how to do what I want using sessions.
Then get_context_data popped into my head and I decided to try that, calling get_random in it. Using that adds the dict from get_random to the context so I can access it in the template like so:

{% for quote in quotes %}
        <p>{{ forloop.counter }}. {{ quote.text }} ― {{ quote.author }}{% if quote.book %}, {{ quote.book}}{% endif %}</p>
{% endfor %}

Since the quotes set is now in context, I tried to override form_valid using Context.get() to save the relationship. I’m trying to follow The Django template language: for Python programmers | Django documentation | Django
But I get AttributeError at /create/my_collidea/ 'UserPostCreateView' object has no attribute 'dicts'. I tried googling but could not figure out how to solve that error.
Can you help me with more advice? Am I on the right track using get_context_data and form_valid?

from django.template import Context

class UserPostCreateView(LoginRequiredMixin, OwnerEditMixin, CreateView):
    model = UserPost
    fields = ["publish", "status", "title", "content"]
    template_name = "create/post.html"

    def get_form(self):
        form = super().get_form()
        form.fields["publish"].widget = DateInput(attrs={"type": "date"})
        return form
    
    def get_context_data(self, *args, **kwargs):
        context = super().get_context_data(*args, **kwargs)
        context["quotes"] = Quote.get_random()
        return context

    def form_valid(self, form):
        quotes_set = Context.get(self, key="quotes")
        for each in quotes_set:
            pk = int(each["id"])
            q = Quote.objects.get(pk=pk)
            self.object.quotes.add(q)
        self.object = form.save()
        return HttpResponseRedirect(self.get_success_url())

Traceback

Traceback (most recent call last):
  File "/usr/local/lib/python3.9/site-packages/django/core/handlers/exception.py", line 47, in inner
    response = get_response(request)
  File "/usr/local/lib/python3.9/site-packages/django/core/handlers/base.py", line 181, in _get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
  File "/usr/local/lib/python3.9/site-packages/django/views/generic/base.py", line 70, in view
    return self.dispatch(request, *args, **kwargs)
  File "/usr/local/lib/python3.9/site-packages/django/contrib/auth/mixins.py", line 71, in dispatch
    return super().dispatch(request, *args, **kwargs)
  File "/usr/local/lib/python3.9/site-packages/django/views/generic/base.py", line 98, in dispatch
    return handler(request, *args, **kwargs)
  File "/usr/local/lib/python3.9/site-packages/django/views/generic/edit.py", line 172, in post
    return super().post(request, *args, **kwargs)
  File "/usr/local/lib/python3.9/site-packages/django/views/generic/edit.py", line 142, in post
    return self.form_valid(form)
  File "/code/mysite/create/views.py", line 59, in form_valid
    quotes_set = Context.get(self, key="quotes")
  File "/usr/local/lib/python3.9/site-packages/django/template/context.py", line 93, in get
    for d in reversed(self.dicts):

Exception Type: AttributeError at /create/my_collidea/
Exception Value: 'UserPostCreateView' object has no attribute 'dicts'

The ordering clause applies to querysets being generated. If get_random returned a queryset, it would be in the desired order.
However, get_random is not returning a queryset. The get_random method is returning a list. This list is being created by randomly selecting n items from the queryset, in any order. Therefore, it is highly unlikely that the list being generated is going to be in the same sequence as the original queryset.

Handling a form occurs in two parts.

  • Generate the form and send it to the browser
  • Accept the submitted form from the browser and do something with that supplied input.

On the get, when you’re retrieving the original quotes, take the list of the quote PKs and save it in the session.

On the post, get the quote list from the session and use that in your view to save the reference.

Neither of these steps affect the template nor change how you’re generating the visible page. You still want to handle the quotes as you were originally doing. The purpose of adding these steps is to maintain the relationship between the quotes and the comments being submitted.