How to show answer field for each question and save the data?

Hello good Django people!

In my project develpoing a online course app, now i have a new challange :slight_smile:

The idea:
To allow the client to create a test for the course module, and add questions to that test.

Frontend:
Test is rendered on a page of the course. The Student has to submit answers to that test. Then the client will grade the answers. If the student passes the test, he can download the certificate (this is yet to be thought about, not important for this post)

I have managed to create the features for adding the test and questions to that test. Everything is working, in the admin i can see the test and the question that bellong to it.
Been using HTMX and it was really really helpful.

I am totally lost on how to render the test with questions and fields to input the answer and save that answers to database for that Student, so the client can grade the test and give feedback.

These are my models for custom user

from django.db import models
# import abstract user
from django.contrib.auth.models import AbstractUser

# when using file upload path, the error appears when editing profile
def file_upload_path(instance, filename):
    return f'user-{instance.id}/images/{filename}'

# DEFINE OUR CUSTOM USER
class User(AbstractUser):
    class Role(models.TextChoices):
        ADMIN = 'ADMIN', 'Admin'
        OWNER = 'OWNER', 'Owner'
        EMPLOYEE = 'EMPLOYEE', 'Employee'
        MODERATOR = 'MODERATOR', 'Moderator'
        CUSTOMER = 'CUSTOMER', 'Customer'
        STUDENT = 'STUDENT', 'Student'
    
    email = models.EmailField(unique=True, max_length=50)
    username = models.CharField(unique=True, max_length=50)
    role = models.CharField(max_length=20, choices=Role.choices, null=True, blank=True)
    phone_number = models.CharField(max_length=20, null=True, blank=True)
    image = models.ImageField(upload_to=file_upload_path, default='default-images/profile.png', null=True, blank=True)

    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = ['username']

    agree_to_terms = models.BooleanField('Slažem se sa uslovima', default=False, blank=False, null=False)

    def __str__(self):
        return self.email
    
    # to show full name
    def full_name(self):
        return f'{self.first_name} {self.last_name}'
    
    # to check the role
    def get_role(self):
        if self.is_superuser:
            user_role = "Superuser"
        elif self.role == User.Role.ADMIN:
            user_role = 'Admin'
        elif self.role == User.Role.OWNER:
            user_role = 'Owner'
        elif self.role == User.Role.EMPLOYEE:
            user_role = 'Employee'
        elif self.role == User.Role.CUSTOMER:
            user_role = 'Customer'
        elif self.role == User.Role.STUDENT:
            user_role = 'Student'
        else:
            user_role = 'Unknown'
        return user_role

# when user is created, create a profile for that user via signals
class UserProfile(models.Model):
    # relation to User
    user = models.OneToOneField(User, on_delete=models.CASCADE)

    def __str__(self):
        return self.user.email

These are the models for the Course

from django.db import models
from django.conf import settings
from django.urls import reverse
from django.core.validators import FileExtensionValidator
from django.core.exceptions import ValidationError
#import magic
# rich text field
from ckeditor_uploader.fields import RichTextUploadingField
# ili from ckeditor.fields import RichTextField
from django.utils import timezone

def course_file_upload_path(instance, filename):
    return f'user-{instance.created_by.id}/courses/files/{filename}'


class Course(models.Model):
    title = models.CharField(max_length=100, unique=True)
    slug = models.SlugField(max_length=110, unique=True)
    image = models.ImageField(upload_to=course_file_upload_path, default='default-images/post.png')
    description = RichTextUploadingField(null=True, blank=True)
    #description = models.TextField(null=True, blank=True)
    short_description = models.CharField(max_length=100)
    price = models.DecimalField(max_digits=7, decimal_places=2, default='100.00')
    created_at = models.DateTimeField(auto_now_add=True) 
    updated_at = models.DateTimeField(auto_now=True)
    #is_paid = models.BooleanField(default=False)

    # is featured - prikazi u sidebaru na blogu
    is_featured = models.BooleanField('Naglasi kurs', default=False)

    # relations
    # to owner of the course = logged in user who is creating the course
    created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='courses')

    # student user
    # to a user that will attend the course
    student_user = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='enrollments', through='Enrollment')

    def __str__(self):
        return self.title
    
    def get_absolute_url(self):
        return reverse('courses:view_course', args=[str(self.slug)])
    
    def clean(self):
        self.title = self.title.capitalize()
        self.slug = self.slug.lower()

class Enrollment(models.Model):
    # user to be enrolled
    student = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='student_enrollments')
    # course to be enrolled in
    course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='course_enrollments')

    enrolled_at = models.DateTimeField(auto_now_add=True, null=True, blank=True)
    is_paid = models.BooleanField('Placeno',default=False)

    class Meta:
        unique_together = ('student', 'course')

    def __str__(self):
        return self.student.email



class Module(models.Model):
    title = models.CharField(max_length=50)
    tagline = models.CharField(max_length=50)
    created_at = models.DateTimeField(auto_now_add=True)

    # relation to Course
    course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='modules')

    def __str__(self):
        return self.title
    
    class Meta:
        ordering = ['created_at']

class Lesson(models.Model):
    STATUS_CHOICES = (
        ('draft','Draft'),
        ('published','Published'),
        ('waiting_approval','Waiting approval'),
    )
    title = models.CharField(max_length=100, unique=True)
    slug = models.SlugField(max_length=110, unique=True)
    tizer = models.CharField(max_length=100)
    content = RichTextUploadingField(null=True, blank=True)
    #video = models.FileField(upload_to=course_file_upload_path, null=True, blank=True)
    video = models.CharField(max_length=300)
    duration = models.DurationField(help_text='HH:MM:SS', null=True, blank=True)
    created_at = models.DateField(auto_now_add=True)
    updated_at = models.DateField(auto_now=True)

    #order = models.SmallIntegerField()
    order = models.IntegerField()

    status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='published')

    # mark lesson as complete
    is_completed = models.BooleanField(default=False)

    is_free_preview = models.BooleanField(default=False)

    # relations
    course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='lessons')
    module = models.ForeignKey(Module, on_delete=models.CASCADE, related_name='lessons')
    created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='lessons')

    def __str__(self):
        return self.title
    
    def get_absolute_url(self):
        return reverse('courses:view_lesson', args=[str(self.course.slug), str(self.slug)])
    
    def clean(self):
        self.title = self.title.capitalize()
        self.slug = self.slug.lower()

    class Meta:
        unique_together = ('course', 'order')
        ordering = ['order']
    

pdf_ext_validator = FileExtensionValidator(['pdf'])

'''def validate_file_mimetype(file):
    accept = ['application/pdf']
    file_mime_type = magicfrom_buffer(file.read(1024), mime=True)
    print(file_mime_type)
    if file_mime_type not in accept:
        raise ValidationError("Unsupported file type")'''


class Pdf(models.Model):
    title = models.CharField(max_length=100, null=True, blank=True)
    file = models.FileField(upload_to=course_file_upload_path, validators=[pdf_ext_validator], null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    # relation to Lesson
    lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE, related_name='pdfs', null=True, blank=True)

    created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)

    def __str__(self):
        return self.title or "PDF Default Title"
    


class Test(models.Model):
    title = models.CharField(max_length=100)
    description = models.CharField(max_length=200, blank=True, null=True)
    created_at = models.DateTimeField(default=timezone.now)
    updated_at = models.DateTimeField(default=timezone.now)
    minimum_to_pass = models.IntegerField(default=80, help_text='Minimum % needed to pass the test')

    # relation to course Module
    module = models.ForeignKey(Module, on_delete=models.CASCADE)

    # relation to user who is creating the test
    # to owner of the course = logged in user who is creating the course and the test
    created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='tests')
    
    # student user
    # to a user that will take the test
    student_user = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='exams', through="Exam")

    def __str__(self):
        return self.title
    
    def clean(self):
        self.title = self.title.capitalize()
    
    def get_questions(self):
        return self.questions.all()
    

class Exam(models.Model):
    # to Test
    test = models.ForeignKey(Test, on_delete=models.CASCADE)
    # student who will take the test
    student = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='student_exams')

    result = models.IntegerField()

    def __str__(self):
        return self.student.email


class Question(models.Model):
    question_text = models.CharField(max_length=150)
    answer_text = models.CharField(max_length=300, default='answer')

    # relation to Test
    test = models.ForeignKey(Test, on_delete=models.CASCADE, related_name='questions')

    created_at = models.DateTimeField(default=timezone.now)
    updated_at = models.DateTimeField(default=timezone.now)

    def __str__(self):
        return self.question_text


These are the views for adding the questions and the view to take the test

def add_test(request):
    form = TestForm()

    if request.method == 'POST':
        form = TestForm(request.POST)
        if form.is_valid():
            test = form.save(commit=False)
            test.created_by = request.user
            test.save()
            messages.success(request, 'Test is created successfully!')
            return redirect('dashboard:dashboard')
        
    context = {
        'form': form
    }
    return render(request, 'courses/tests/add-test.html', context)


# partial that contains only a form to add a new question
def add_question_form(request):
    #form = QuestionForm()
    context = {
        'form': QuestionForm()
    }
    return render(request, 'courses/tests/partials/add-question-form.html', context)


def create_question(request, pk):
    test = Test.objects.get(pk=pk)
    questions = Question.objects.filter(test=test)
    form = QuestionForm(request.POST or None)

    if request.method == 'POST':
        if form.is_valid():
            question = form.save(commit=False)
            question.test = test
            question.save()
            return redirect('dashboard:question-detail', pk=question.id)
        else:
            return render(request, 'courses/tests/partials/add-question-form.html', {'form':form})
        
    context = {
        'form': form,
        'test': test,
        'questions': questions
    }
    return render(request, 'courses/tests/create-test.html', context)


def question_detail(request, pk):
    question = Question.objects.get(id=pk)
    context = {
        'question':question,
    }
    return render(request, 'courses/tests/partials/question_detail.html', context)

def question_delete(request, pk):
    question = Question.objects.get(id=pk)
    question.delete()
    return HttpResponse('')

def question_update(request, pk):
    question = Question.objects.get(id=pk)
    form = QuestionForm(request.POST or None, instance=question)

    if request.method == 'POST':
        if form.is_valid():
            question = form.save()
            return redirect('dashboard:question-detail', pk=question.id)
        
    context = {
        'form':form,
        'question':question,
    }
    return render(request, 'courses/tests/partials/add-question-form.html', context)


def test(request, pk):
    test = Test.objects.get(id=pk)
    context = {
        'test':test
    }
    return render(request, 'courses/tests/take-test.html', context)

Everything is working for now, but i need help on how to make possible for Students to take the test.
I have to say this is not really a quiz or a test, the client wants to create questions on which the Student needs to answer and send that data back to them, so they can evaluate it.

I hope you understand the idea and can help!

Thank you again!
Tomislav