Mega Tutorial: How to build a Trello code in Django (Part 3)
Home Django Mega Tutorial: Build a Trello Clone - Part 3

Mega Tutorial: Build a Trello Clone - Part 3

13 December, 2024

This is the 3nd part of the Build a Trello Clone Tutorial. In this part we are going to focus on implementing the basic functionality of the user dashboard, where the user can view and create boards. We are going to see what a Django ClassBasedView how to use a GenericView. We are also going to write out first javascript code to make the dashboard interactive.

Planning

Go into your Trello account and have a look at the main page, the one you get redirected to after authenticating.

As you can see the user dashboard is listing the existing boards. Also, from here the user can create a new board. This 3rd part of this tutorial will be exactly about this.

We need to create a view that lists all the available boards. By available boards I'm referring to the boards the user created, plus the boards the user was invited to. We're going to make use of the generic implementations from Django's Class Based Views. In this case, it's going to be the ListView.

We also need to create a section on top with all the user's favorite boards.

After, we are going to add the Create Board functionality to this view.

Let's get to it!

Creating the Django app

As always, let's use the Django command to init the boards application:

1
python manage.py startapp boards

Add the boards application to INSTALLED_APPS:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'accounts',
    'boards',   # <--
]

Enrich Board model

It's a good idea to write common queries in the model as methods so that:

  1. You don't repeat yourself
  2. You don't get it wrong

Let's enrich the Board model with:

  1. card_count - to know how many tasks there are in a board (we're going to write this as a property)
  2. get_available - To get all the available boards to a user (we're going to write this as a static method)
  3. get_favorites - To get all the favorite available boards for a user (also static method)

Here's how that looks like:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Board(TimeStampedModel):
    title = models.CharField(max_length=128)
    owner = models.ForeignKey(User, on_delete=models.CASCADE)

    @property
    def card_count(self):
        return Card.objects.filter(column__board=self).count()

    @staticmethod
    def get_available(user):
        return Board.objects.prefetch_related(
            Prefetch('invites', queryset=Invite.objects.filter(user=user))
        ).filter(Q(invites__user=user) | Q(owner=user)).distinct()

    @staticmethod
    def get_favorites(user):
        return Board.get_available(user).prefetch_related(
            Prefetch('favorited_by', queryset=Favorite.objects.filter(user=user))
        ).filter(favorited_by__user=user)

    def __str__(self):
        return f"{self.owner} > {self.title}"

Writing Tests in Django

Even though this tutorial is not focused on writing tests, we can a short detour to show how to check the behaviour of these new methods we wrote.

Here's how to think about writing these tests. We want to create a few scenarios and see if the result matches out expectations. Let's create a few users and assign them to some boards, either as owners or as invitees.

We also want to check some edge cases, like:

  1. What happens a user has no favorite boards
  2. What happens when a user has marked all boards as favorite
  3. What happens if a user marks as favorite a board he doesn't have access to
  4. What happends if a user invites himself to a board he owns

If you don't already have it, go and create a tests.py file inside the boards Django application. Here are the tests I wrote. If you find a corner case interesting, add it to the test scenarios.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
from django.contrib.auth.models import User
from django.test import TestCase

from boards.models import Board, Invite, Favorite


class BoardsTestCase(TestCase):
    def setUp(self):
        # Create users
        self.john = User.objects.create_user(
            username="john",
            email="john@example.com",
            password="j0hn"
        )
        self.mindy = User.objects.create_user(
            username="mindy",
            email="mindy@example.com",
            password="m1ndy"
        )
        self.kylie = User.objects.create_user(
            username="kylie",
            email="kylie@example.com",
            password="kyli3"
        )

        self.board1 = Board.objects.create(title="Board 1", owner=self.john)
        self.board2 = Board.objects.create(title="Board 2", owner=self.john)
        self.board3 = Board.objects.create(title="Board 3", owner=self.mindy)

        Invite.objects.create(user=self.john, board=self.board3)
        Invite.objects.create(user=self.mindy, board=self.board1)

        # Invite mindy to her own board
        Invite.objects.create(user=self.mindy, board=self.board3)

        Invite.objects.create(user=self.kylie, board=self.board2)
        Invite.objects.create(user=self.kylie, board=self.board3)

        # john favorited all the boards he has access to
        Favorite.objects.create(user=self.john, board=self.board1)
        Favorite.objects.create(user=self.john, board=self.board2)
        Favorite.objects.create(user=self.john, board=self.board3)

        # mindy favorited none

        # kylie favorited 1 (not available) and 2
        Favorite.objects.create(user=self.kylie, board=self.board1)
        Favorite.objects.create(user=self.kylie, board=self.board2)

    def test_get_available_boards(self):
        # - john -
        available_boards_for_john = Board.get_available(self.john)
        expected_available_boards_for_john = Board.objects.filter(
            pk__in=[self.board1.pk, self.board2.pk, self.board3.pk])

        self.assertQuerysetEqual(
            available_boards_for_john,
            expected_available_boards_for_john,
            ordered=False,
        )

        # - mindy -
        available_boards_for_mindy = Board.get_available(self.mindy)
        expected_available_boards_for_mindy = Board.objects.filter(
            pk__in=[self.board1.pk, self.board3.pk])

        self.assertQuerysetEqual(
            available_boards_for_mindy,
            expected_available_boards_for_mindy,
            ordered=False,
        )

        # - kylie -
        available_boards_for_kylie = Board.get_available(self.kylie)
        expected_available_boards_for_kylie = Board.objects.filter(
            pk__in=[self.board2.pk, self.board3.pk])

        self.assertQuerysetEqual(
            available_boards_for_kylie,
            expected_available_boards_for_kylie,
            ordered=False,
        )

    def test_get_favorite_boards(self):
        # - john -
        favorite_boards_for_john = Board.get_favorites(self.john)
        expected_favorite_boards_for_john = Board.objects.filter(
            pk__in=[self.board1.pk, self.board2.pk, self.board3.pk])

        self.assertQuerysetEqual(
            favorite_boards_for_john,
            expected_favorite_boards_for_john,
            ordered=False,
        )

        # - mindy -
        favorite_boards_for_mindy = Board.get_favorites(self.mindy)
        expected_favorite_boards_for_mindy = Board.objects.none()

        self.assertQuerysetEqual(
            favorite_boards_for_mindy,
            expected_favorite_boards_for_mindy,
            ordered=False,
        )

        # - kylie -
        favorite_boards_for_kylie = Board.get_favorites(self.kylie)
        expected_favorite_boards_for_kylie = Board.objects.filter(
            pk__in=[self.board2.pk])

        self.assertQuerysetEqual(
            favorite_boards_for_kylie,
            expected_favorite_boards_for_kylie,
            ordered=False,
        )

In order to run the tests, Django provides a simple command that discovers all the tests in the registered applications and runs them:

python manage.py test

Here's the expected output:

python manage.py test
Found 2 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..
----------------------------------------------------------------------
Ran 2 tests in 0.826s

OK
Destroying test database for alias 'default'...

Creating the Boards View

Let's go to boards/views.py and write our view code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from django.contrib.auth.decorators import login_required
from django.shortcuts import redirect
from django.urls import reverse_lazy
from django.utils.decorators import method_decorator
from django.views.generic import ListView

from .models import Board


@method_decorator(login_required(login_url=reverse_lazy('login')), name='dispatch', )
class BoardListView(ListView):
    template_name = "boards/board_list.html"

    def get_queryset(self):
        return Board.get_available(self.request.user)

    def get_favorites_queryset(self):
        return Board.get_favorites(self.request.user)

    def get_context_data(self, *, object_list=None, **kwargs):
        context = super().get_context_data()
        context["favorite_object_list"] = self.get_favorites_queryset()
        return context

Let's quickly go over this code. Like we said, Django offers several abstract class-based views (CBV). One of these is ListView. The purpose of this type of view is to list objects belonging to a certain model. In order to customize the query that selects what subset of objects we are interested in displaying, we need to override the get_queryset method. The ListView will assign the result of this method to the object_list inside the template.

Notice how we also added a get_favorite_queryset method. This is because we also want the list of favorite boards. We add this list in the context that is being passed to the template (this is done by overriding the get_context_data method).

Add URL scheme

Head over to boards/urls.py and add our BoardListView to urlpatterns:

1
2
3
4
5
6
7
from django.urls import path

from .views import BoardListView

urlpatterns = [
    path('', BoardListView.as_view(), name='boards'),
]

Let's update taskleap/urls.py by hooking up the boards url scheme:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from django.contrib import admin
from django.urls import path, include

from accounts import urls as accounts_urls
from boards import urls as boards_urls

urlpatterns = [
    path('admin/', admin.site.urls),
    path('accounts/', include(accounts_urls)),
    path('boards/', include(boards_urls)),
]

Creating the Boards Template

Let's continue our pattern and create a base template for the boards application.

Base Template

We need all pages to have a nav bar, a main container and a footer. Let's write the base template in boards/templates/boards/base.html:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
{% extends "master.html" %}

{% block body %}

    {% include "boards/partial/nav.html" %}

    <!-- Main Content -->
    <main class="flex-grow container mx-auto px-4 py-8 bg-white">
        {% block content %}{% endblock %}
    </main>

    <!-- Footer -->
    <footer class="bg-black py-4">
        <div class="container mx-auto px-4 text-center text-white">
            <p>© 2024 Your Company. All rights reserved.</p>
        </div>
    </footer>
    </body>
{% endblock %}

Notice how we included a boards/partial/nav.html template. Let's write it now.

Nav Bar Template

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<nav class="bg-black py-4">
    <div class="container mx-auto px-4 flex justify-between items-center">
        <h1 class="text-2xl font-bold text-white">TaskLeap</h1>
        <div class="flex items-center space-x-4">
            <ul class="flex space-x-4">
                <li><a href="#" class="text-white hover:text-gray-400">Home</a></li>
                <li><a href="#" class="text-white hover:text-gray-400">Boards</a></li>
                <li><a href="#" class="text-white hover:text-gray-400">Settings</a></li>
            </ul>

            <!-- Logout Button -->
            <a href="{% url "logout" %}"
               class="flex items-center bg-gray-700 hover:bg-gray-800 text-white font-semibold py-2 px-4 rounded">
                <!-- Heroicons Logout Icon -->
                <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
                     stroke="currentColor" class="w-5 h-5 mr-2">
                    <path stroke-linecap="round" stroke-linejoin="round"
                          d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M18 12h-9m0 0l3-3m-3 3l3 3"/>
                </svg>
                Logout
            </a>
        </div>
    </div>
</nav>

Notice how the used {% url "logout" %} template tag. This translates the view name logout (check /accounts/urls.py) to the actual URL.

Board List Template

Ok, we're finally here! Let's write the boards/template/boards/board_list.html template.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
{% extends "boards/base.html" %}

{% block content %}
    <div>
        {% if favorite_object_list %}
            <h2 class="text-xl font-semibold mb-6">Your Favorite Boards</h2>

            <div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
                {% for object in favorite_object_list %}
                    {% include "boards/partial/board_card.html" %}
                {% endfor %}
            </div>
        {% endif %}

        <h2 class="text-xl font-semibold mb-6">Your Boards</h2>

        <div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
            {% for object in object_list %}
                {% include "boards/partial/board_card.html" %}
            {% endfor %}

            <!-- Create Board Card -->
            <div class="bg-gray-50 border border-gray-300 rounded-lg p-4 flex flex-col items-center justify-center shadow hover:bg-gray-100 cursor-pointer">
                <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
                     stroke="currentColor" class="w-10 h-10 text-gray-500 mb-2">
                    <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/>
                </svg>
                <p class="text-gray-500 font-medium">Create New Board</p>
            </div>
        </div>

    </div>
{% endblock %}

Notice how we created a sub template for displaying board cards. Here's that template (boards/template/partial/board_card.html):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<div class="relative bg-gray-100 border border-gray-300 rounded-lg p-4 shadow">
    <!-- Star Icon -->
    <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
         class="absolute top-2 right-2 size-6 text-yellow-500">
        <path stroke-linecap="round" stroke-linejoin="round"
              d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z"/>
    </svg>

    <h3 class="text-lg font-medium mb-4">{{ object.title }}</h3>
    <div class="flex items-center justify-between text-gray-700">
        <!-- Number of Tasks -->
        <p>Tasks: <span class="font-semibold">{{ object.card_count }}</span></p>
        <!-- Creation Date -->
        <p class="font-mono text-sm text-gray-500">Created: {{ object.created|date:'Y-m-d' }}</p>
    </div>
</div>

Testing in browser

Open your browser and go in the admin area. Create some Board objects. Also, go and create some Favorite objects.

Let's open the browser and see how our board list looks like:

TaskLeap board list
TaskLeap board list

Writing a template filter

Let's start enhancing our web application. Let's display a solid star for the favorite boards and only the outline of a star for the others.

Let's create boards/templatetags/__init__.py and boards/templatetags/board_tags.py.

Inside boards/templatetags/board_tags.py let's create the is_favorite filter:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
from django import template
from boards.models import Favorite

register = template.Library()


@register.filter
def is_favorite(board, user):
    if user.is_authenticated:
        return Favorite.objects.filter(board=board, user=user).exists()
    return False

We're going to use this filter to check if the current user favorited a board. Let's update the boards/template/partial/board_card.html template:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{% load board_tags %}

<div class="relative bg-gray-100 border border-gray-300 rounded-lg p-4 shadow">
    {% if object|is_favorite:request.user %}
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
         class="absolute top-2 right-2 size-6 text-yellow-500">
        <path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z" clip-rule="evenodd" />
    </svg>
    {% else %}
    <!-- Star Icon -->
    <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
         class="absolute top-2 right-2 size-6 text-yellow-500">
        <path stroke-linecap="round" stroke-linejoin="round"
              d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z"/>
    </svg>
    {% endif %}

    <h3 class="text-lg font-medium mb-4">{{ object.title }}</h3>
    <div class="flex items-center justify-between text-gray-700">
        <!-- Number of Tasks -->
        <p>Tasks: <span class="font-semibold">{{ object.card_count }}</span></p>
        <!-- Creation Date -->
        <p class="font-mono text-sm text-gray-500">Created: {{ object.created|date:'Y-m-d' }}</p>
    </div>
</div>

Here's our new updated board list:

TaskLeap board list with favorites
TaskLeap board list with favorites

Tweaking the URL schema

Now that we've created the boards page, let's adjust the URL schema so that after logging in we get redirected here. We need to change the next_page param of the LoginView to point to boards. It's also a good idea to add a RedirectView in the place of the existing home URL to point us to the boards section.

Let's edit accounts/urls.py:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
from django.urls import path
from django.views.generic import RedirectView
from django.contrib.auth.views import LoginView, LogoutView


from accounts.views import RegisterView, dashboard

urlpatterns = [
    path('login', LoginView.as_view(
        template_name="accounts/login.html",
        redirect_authenticated_user=True,
        next_page="boards",
    ), name='login'),
    path('register', RegisterView.as_view(), name='register'),
    path('logout', LogoutView.as_view(
        next_page="login",
    ), name='logout'),
    path('',
         RedirectView.as_view(url="boards"),
         name='home'
    ),
]

Feel free to also delete the dashboard view in accounts/views.py.

Creating Boards

Let's implement the create boards functionality. This is what we need to do:

  1. We need to create an Alpine.js application
  2. Create a modal (not model!) containing a simple form
  3. Hide the modal by default but have it shown when we click on the "Add Board" button
  4. Write the create board functionality somewhere in the backend

Here's how I modified the boards/templates/boards/board_list.html template:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
{% extends "boards/base.html" %}

{% block content %}
    <!-- Create Alpine.js Application -->
    <div x-data="{ openModal: false }">
        {% if favorite_object_list %}
            <h2 class="text-xl font-semibold mb-6">Your Favorite Boards</h2>

            <div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
                {% for object in favorite_object_list %}
                    {% include "boards/partial/board_card.html" %}
                {% endfor %}
            </div>
        {% endif %}

        <h2 class="text-xl font-semibold mb-6 mt-10">Your Boards</h2>

        <div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
            {% for object in object_list %}
                {% include "boards/partial/board_card.html" %}
            {% endfor %}

            <!-- Create Board Card -->
            <!-- Open Modal on Click -->
            <div @click="openModal = true"
                 class="bg-gray-50 border border-gray-300 rounded-lg p-4 flex flex-col items-center justify-center shadow hover:bg-gray-100 cursor-pointer">
                <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
                     stroke="currentColor" class="w-10 h-10 text-gray-500 mb-2">
                    <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/>
                </svg>
                <p class="text-gray-500 font-medium">Create New Board</p>
            </div>
        </div>

        <!-- Include the Modal -->
        {% include "boards/partial/create_board_modal.html" %}
    </div>
{% endblock %}

Notice how I add the Alpine.js application inside the div container. It is a very simple application. It only has one state attribute: openModal, that is defaulting to false.

Next we added an on @click functionality to the "Create Board" button. We just set openModal to true.

Lastly, we included the boards/partial/create_board_modal.html template we haven't coded, yet. Let's do that:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<!-- Create Modal -->
<div>
    <!-- Background -->
    <div x-show="openModal"
         class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-100"
         x-transition.opacity>

        <!-- Modal Content -->
        <div class="bg-white rounded-lg shadow-lg w-1/2 p-6" @click.away="openModal = false">
            <h2 class="text-xl font-semibold mb-4">
                Create New Board
            </h2>
            <form method="post">
                {% csrf_token %}
                <div class="mb-4">
                    <label for="title" class="block text-gray-700 font-medium mb-2">
                        Board Title
                    </label>
                    <input type="text" id="title" name="title" required
                           class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring focus:ring-gray-200"
                           placeholder="Enter board title"/>
                </div>
                <div class="flex justify-end space-x-2">
                    <button @click="openModal = false" type="button"
                            class="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400">
                        Cancel
                    </button>
                    <button type="submit" class="px-4 py-2 bg-black text-white rounded-lg hover:bg-gray-800">
                        Create
                    </button>
                </div>
            </form>
        </div>
    </div>
</div>

Using x-show="openModal" makes the modal be visible only when the openModal attribute on the Alpine.js application is set to true.

Clicking outside the modal hides it: @click.away="openModal = false". Also, clicking on the Cancel does the same: @click="openModal = false".

We've set the form method to post. We need to write some code on the current view that processes this POST data and creates a Board model.

TaskLeap Create Modal
TaskLeap Create Modal

There might be better ways to do this, but I found that the easiest is to just add post method to the view. Another way would have been to create another view altogether.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@method_decorator(login_required(login_url=reverse_lazy('login')), name='dispatch', )
class BoardListView(ListView):
    template_name = "boards/board_list.html"

    def post(self, request, *args, **kwargs):  # <--
        title = request.POST.get("title")
        if title:
            Board.objects.create(title=title, owner=self.request.user)
        return redirect("boards")

    def get_queryset(self):
        return Board.get_available(self.request.user)

    def get_favorites_queryset(self):
        return Board.get_favorites(self.request.user)

    def get_context_data(self, *, object_list=None, **kwargs):
        context = super().get_context_data()
        context["favorite_object_list"] = self.get_favorites_queryset()
        return context

Here's how everything works together:

Create Board Walkthrough
Create Board Walkthrough