Aside from walking through painful "hello world" tutorials or setting project configurations, there isn't anything more fundamental about web frameworks than creating views. We've already tackled the uninspiring ordeal of setting up a Django project in our last post, which allows us to turn our full attention to the most fundamental component of web frameworks.

Earlier MVC frameworks (like Django) championed the enforcement of separation of concerns to mainstream web development. The concept of encapsulating logic into single-purpose modules was hardly a new one in computer science at the time. Still, most employed web developers weren't exactly computer scientists in the early 2000s. Unless you happened to be building impactful software, most companies were fine doing horrible things with PHP, Drupal, Wordpress, or whichever awful LAMP stack variation happened to be your thing at the time.

What We Need to Know About Django Views

Whether you're a veteran of MVC frameworks or a Django newcomer, the concept of views in a web framework seems straightforward. In essence, views are chunks of logic that accept incoming requests to a URL in your app and outputs the proper response. We begin by dissecting views into their most straightforward pattern:

  1. Parse an HTTP request form a user attempting to reach a page.
  2. Output the response based on the request.

Normally that'd be the crux of it, but Django isn't your "average" framework... it's The Grandaddy of all Frameworks. Django has been around long enough to have seen or dealt with every software design challenge as they pertain to web frameworks. Avoiding tedious patterns is undoubtedly desirable, but this may come at the cost of complexity to new users. See, Django actually has three "types" of views:

  • Function-based views: Defining views as Python functions is standard practice for most developers, as it's generally quicker to write simplistic views as functions. There's nothing class-based views can achieve that Function-based views cannot; the difference comes down to shortcuts/mixins available to class-based views.
  • Class-based views: While they may appear bulkier than their function-based counterparts at first glance, class-based views typically save time over the long run for several reasons. The most advantages of class-based Django views are their ability to dodge repetitive boilerplate code via the use of Mixins, which automatically handle common yet time-consuming view patterns, such as views that handle forms, redirects, etc. Just like regular Python classes, class-based views can also extend one another to share common logic between views.
  • Model-driven class-based views: I'm not sure if there's an official name for these, but "model-driven class-based views" are class-based views that specifically deal with models. These are views which are coupled with data models to produce views that list or mutate records associated with Django data models.

What We'll Cover

This post is focused explicitly on function-based views. As we break down the anatomy of Django views, this tutorial should equip you to write Django views that handle a relatively impressive amount of user interaction. The views we'll build today will be capable of:

  • Fetching and utilizing user metadata.
  • Handling form validation and submission.
  • Creating, modifying, and fetching records from a database.
  • Serving user profiles with dynamic URLs.

The full source code for this tutorial is hosted in an accompanying Github repository. I've also deployed this as a working demo to showcase the functionality covered in this post:

Django Views Tutorial - Hackers and Slackers
Function and class-based Django view repository.

NOTE: If you're new to Django, I'd strongly recommend starting with the previous post in this series, which details getting set up with a database and so forth. Don't try to skip steps, wise guy.

Putting the "V" in MVC

You probably already know MVC stands for Model, View, Controller (I know, I know... bear with me on this). The reason I rehash this concept is to emphasize what views are and what they are not. Roll your eyes if you want, but we both know about the 800-line view functions you've created, and might even still be creating. So yeah, this applies to you:

MVC (or "MTV") in Django

Django lacks terminology for "controllers," hence its self-proclaimed status as Model, Template, View framework. Aside from the unfortunate acronym, the underlying concepts of MVC remain the same: Templates are user-facing interfaces, Views are business logic which parses contextual input to serve templates, and Models are abstractions of data in our database.

In our example above, both of our views accomplish the same underlying goal: they take an input and produce an output. It isn't important to focus on whether the "input" coming is from the client-side or triggered by a backend event. The point is that view logic should strive to be as straightforward as a transaction via a Burger King drive-through. Views logic isn't the place for complicated data analysis or time-consuming batch services (that's what message queues are for). Your view's focus should be getting the user what they want, or at least some feedback, as quickly as possible.

Deconstructing a View

Whenever you visit a page on the internet, your browser sends a request which contains things like headers (metadata about who you are), parameters (variables), HTTP method, and occasionally a body of data. Upon doing so, you expect to receive a response in return, which consists of mostly the same pieces. Depending on the nature of the request you make (i.e., whether or not you're logged in), you should expect a different response to come back to you.

Given that a view's only purpose is to handle user requests, your views should be designed to do nothing more than parse requests to serve a proper response.

Django views share the same terminology as the things they're abstracting. Take requests, for instance: when your webserver (like Nginx or whatever) receives a request, the request information is forwarded to Django in a neat request object. This object contains everything you'd expect. Here are just a few examples:

  • Headers: Every incoming request contains headers sent by the client's browser to Django as part of the request. request.headers.items() returns a list of tuples per header, where the first value is the header name (like Content-Type), and the second is the header value (such as text/plain).
  • Cookies: request.COOKIES contains cookie and session identifier information which is unique to the user making the request. This gets returned as a dictionary of values, such as a session identifier, and the user's csrftoken.
  • Path: request.path contains the filepath of the page the user is attempting to hit (in our case, this would be /function_views/get_json).
  • Method: The HTTP method of the incoming request (GET, POST, etc.) can be determined with request.method. This is useful for views that accept multiple HTTP methods and contain logic unique to dealing with each case.
  • Body: request.body contains data passed along in the body of a POST request (if applicable).
  • Scheme: request.scheme: Whether the user attempted an HTTP or HTTPS request.

On the other hand, responses are handled in Django via a built-in function called HttpResponse(). Views return responses to users expecting to pass along three parts:

  • Content: Such as a page template or JSON response
  • Status: HTTP response code (200, 404, etc)
  • Content-type: The type of content being returned.

The most important thing to acknowledge about the above three parts is the absence of anything else. Within the confines of the above, the most complicated request/response scenario might involve parsing some user input, manipulating some data, and providing a response of what happened. This is what we should strive to accomplish in our views: getting responses out for requests coming in. Our views should be the web equivalent of shitty fast-food drive-thru windows.

View #1: JSON Response

Views don't get more simple than serving some simple JSON. Here's the most simple conceivable view we can create:

from django.http import JsonResponse


def get_json_view(request):
    """Return request metadata to the user."""
    data = {'url': request.path}
    return JsonResponse(data)
views.py

Views that return JsonResponse will convert a Python dictionary into JSON object and serve the resulting JSON to the user. When we visit the URL associated with this view, we'll receive a JSON object which tells us the path of the view we're looking at, courtesy of request.path.

{
  "path": "/function_views/get_json"
}
get_json_view() response

There's a lot more we can find out about a user from Django's request object besides the path they're hitting. Let's beef up our view by checking out the headers and cookies coming from the requesting user:

from django.http import JsonResponse


def get_json_view(request):
    """Return request metadata to the user."""
    data = {'received_headers': dict(request.headers.items()),
            'client_cookies': request.COOKIES,
            'path': request.path}
    return JsonResponse(data)
views.py

All we've done is pull attributes from the incoming Django request and arranged them in a neat dictionary for our template to decipher. If you recall from our breakdown earlier, we should expect this dictionary to contain:

  • received_headers: List of headers receive by requesting client.
  • client_cookies: Informat
  • request.path: The requested URL.

Let's see what our view returns now:

{
  "received_headers": {
    "Content-Length": "",
    "Content-Type": "text/plain",
    "Host": "127.0.0.1:8000",
    "Connection": "keep-alive",
    "Cache-Control": "max-age=0",
    "Sec-Ch-Ua": "Google Chrome 80",
    "Upgrade-Insecure-Requests": "1",
    "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.163 Safari/537.36",
    "Sec-Fetch-Dest": "document",
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
    "Sec-Fetch-Site": "same-origin",
    "Sec-Fetch-Mode": "navigate",
    "Sec-Fetch-User": "?1",
    "Referer": "http://127.0.0.1:8000/",
    "Accept-Encoding": "gzip, deflate, br",
    "Accept-Language": "en-US,en;q=0.9",
    "Cookie": "session=ce55ee3f-03a8-4e18-9c27-ead022a1658e; csrftoken=iwWutpCyivQ5jbIHUdEtXf7XFDWFDEJmAf59jnNM9wjEf4sfvaV9tN5mtrL2Gu; djdt=show"
  },
  "client_cookies": {
      "session": "ce55ee3f-03a8-4e18-9c27-ead022a1658e",
      "csrftoken": "iwWutpCyivQ5jbIHUdEtXf7XFDWFDEJmAf59jnNM9wjEf4sfvaV9tN5mtrL2Gu",
      "djdt": "show"
  },
  "path": "/function_views/get_json"
}
get_json_view() response

Holy hell, that's me! You can see quite a bit about me: I'm using Chrome, I'm on a Mac, I came from the homepage, and my CSRF token is... well, let's ignore that part. You can check out a live version of this route here to see what comes for your own headers: https://django.hackersandslackers.app/function_views/get_json/

Django has a decorator called @require_http_methods() we can use to restrict access to views to only honor certain HTTP methods (the same way Flask does)! If we wanted to limit our view to only accept GET requests, our view would look like this:
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods


@require_http_methods(["GET"])
def get_json_view(request):
    """Return request metadata to the user."""
    data = {'received_headers': dict(request.headers.items()),
            'client_cookies': request.COOKIES,
            'path': request.path}
    return JsonResponse(data)

View #2: Dynamic Page Template

Let's take the same idea of displaying a user's client information and apply it in a  user-friendly page template. The main difference between a view serving JSON versus is a page template is the type of response our view returns: where our JSON view returns JsonResponse, this view returns render(). render() accepts three arguments:

  1. The incoming request object.
  2. The  page template to serve to the user by specifying a relative filepath to a template.
  3. A dictionary of values called context.

Understanding contexts as they relate to Django page templates is important. When a view (such as get_template_view() below) renders a page template, we only have one shot, one opportunity, to pass information to the page before it has to chance to render what we want. In one moment... would you capture it? Or just let it slip?

The good news is it isn't hard to pass information to page templates at all. context is a Python dictionary that can accept any number of keys and values, where some of those values could even be lists. Similarly to how we created a dictionary in our last view to serve as JSON, we'll be passing the same information to a page template like so:

from django.shortcuts import render
from django.views.decorators.http import require_http_methods


@require_http_methods(["GET"])
def get_template_view(request):
    """Renders a page template."""
    context = {'title': 'GET Page Template View',
               'path': request.path
               'received_headers': request.headers.items(),
               'client_cookies': request.COOKIES}
    return render(request, 'function_views/get.html', context)
views.py

This time around, we can't simply convert our dictionary into JSON, serve it, and call it a day. We need to analyze the type of data contained in our context object and contemplate the best way to display this to fellow human beings.

This is the end result we'll be gunning for while creating our page template, get.html:

https://django.hackersandslackers.app/function_views/get_template/

Our context dictionary has four keys: title, path, received_headers, and client_cookies. Of these four keys, we have two different data structure patterns: title and path are single-value strings, while received_headers and client_cookies are lists of tuples.

For the former, page templates can display the value of any "single-value" key/value pair through the use of double curly ({{  }}) brackets. Including  {{ title }} and {{ path }} in our template renders to GET Page Template View and function_views/get.html respectively:

{% extends 'layout.html' %}
{% load static %}

<header>
  <h1 class="page-title">{{ title }}</h1>
  <div class="path">{{ path }}</div>
</header>

...
get.html

That was the easy part; now we need to render HTML tables from lists of tuples to get our received_headers and client_cookies tables. Luckily Django's templating system allows us to loop through lists programmatically, as so:

{% for header in received_headers %}
    ...
{% endfor %}
get.html

Each header in our list of received_headers can be used to build rows in our table. Since we're looping through tuples, we can create a 2-column table where the first column contains header names (the first value in our tuple), and the second column contains header values (the second value). We achieve this by breaking each tuple into {{ header.0 }} and {{ header.1 }}.

...

<table class="header-table">
  <thead>
    <tr>
      <th>Header Key</th>
      <th>Header Value</th>
    </tr>
  </thead>
  <tbody>
    {% for header in received_headers %}
    <tr>
      <td class="key-cell">
        <span>{{ header.0 }}</span>
      </td>
      <td class="value-cell">
        <span>{{ header.1 }}</span>
      </td>
    </tr>
    {% endfor %}
  </tbody>
</table>

...
get.html

We repeat this again for the client_cookies table, and our page template is complete!

{% extends 'layout.html' %}
{% load static %}

<header>
  <h1 class="page-title">{{ title }}</h1>
  <div class="path">{{ path }}</div>
</header>

{% block content %}
<div class="viewport">
  {% include 'partials/header.html' %}
  <div class="container">

    <!-- Headers received from client. -->
    <div class="table-container headers">
      <h2>Request Headers</h2>
      <table class="header-table">
        <thead>
          <tr>
            <th>Header Key</th>
            <th>Header Value</th>
          </tr>
        </thead>
        <tbody>
          {% for header in received_headers %}
          <tr>
            <td class="key-cell">
              <span>{{ header.0 }}</span>
            </td>
            <td class="value-cell">
              <span>{{ header.1 }}</span>
            </td>
          </tr>
          {% endfor %}
        </tbody>
      </table>
    </div>

    <!-- Cookies received from client. -->
    <div class="table-container cookies">
      <h2>Request Cookies</h2>
      <table class="cookie-table">
        <thead>
          <tr>
            <th>Cookie Property</th>
            <th>Value</th>
          </tr>
        </thead>
        {% for cookie in client_cookies %}
        <tr>
          <td class="key-cell">
            <span>{{ cookie.0 }}</span>
          </td>
          <td class="value-cell">
            <span>{{ cookie.1 }}</span>
          </td>
        </tr>
        {% endfor %}
      </table>
    </div>

  </div>
</div>
{% endblock %}
get.html

See it working in action here: https://django.hackersandslackers.app/function_views/get_template/

View #3: User-Generated Content

Now that you have your bearings, its time to what any good driving instructor would do to a 16-year old with a learner's permit: force them into a four-lane highway. I'm not sure if you arrived here expecting to walk away with a solid understanding of Django's data layer or form handling, but the only way to learn how to swim is to drown. Or something like that.

Before I bombard you by recklessly blowing through half-explained source code, I need to acknowledge that building interactive experiences (such as those involving forms and databases) comes naturally to nobody. We're about to cover a reasonably large amount of information very quickly, and there's a good chance you might get lost or caught up on a few details you feel like you might not understand. This isn't your fault. It's hard to internalize these things all at once, especially when forms and data model ORMs are topics that could span posts of their own. Don't sweat it.

Anyway, buckle in. We're going 0-100 on a path down nostalgia road to recreate one of the best innovations of 90s internet: the anonymous guestbook:

https://django.hackersandslackers.app/function_views/form/

For those of you too young to remember the early days of the internet, "guestbooks" were rampant through personal websites belonging to internet communities such as GeoCities, LiveJournal, DeadJournal, etc. They empowered people like Karen from Minnesota to interact with visitors on their site by letting users submit "guestbook" entries in a publicly visible feed. As it turned out, most of Karen's user base often turned out to be malicious bots, trolls, adult-film stars enthusiastic about their live streaming hobby, Nigerian princes, and so forth. In other words, they were cultural melting pots exemplifying the internet's ability to bring people together. Truly beautiful.*

...I am instantly going to regret building this and putting it on the public internet.

To build this view, we're going to need two things: a form to accept user input, and a data model representing journal entries. We'll start by creating a form in forms.py.

Creating a Form

Django forms are Python classes that extend Django's forms.Form mixin. To add fields to our form, we simply set variables equal to field styles imported from django.forms as seen above.

from django import forms
from django.forms import CharField, Textarea, IntegerField

class GuestBookForm(forms.Form):
    name = CharField(label="Your name",
                     required=True,
                     strip=True)
    msg = CharField(label="Leave a message.",
                    required=True,
                    widget=Textarea(attrs={'cols': '30', 'rows': '5'}),
                    min_length=10)
forms.py

Django's form creation syntax is pretty easy to understand at first glance. At the risk of going into an endless tangent about forms, I'd encourage you to dissect the subtleties of Django forms on your own accord before I ramble into a tangent about form creation.

Creating a Data Model

Each guest book "message" will be saved as a record in our database. Each record we create will contain a user's message, the user's name, and creation date. This will serve as the data model for guestbook entries users submit via the form we created:

from django.db import models
from datetime import datetime


class Message(models.Model):
    """Message left by a user in a guest book."""
    id = models.AutoField(primary_key=True)
    name = models.CharField(max_length=255)
    message = models.TextField()
    created_at = models.DateTimeField()
models.py

With your model created, run migrations via manage.py to tell Django to create a table for your new model:

$ python3 manage.py makemigrations
$ python3 manage.py migrate

This should automatically recognize the model created in models.py and create a table in your SQL database.

Handling Forms in Function-based Views

From the perspective of a human being, every form we encounter on the web happens in either one of two contexts. We either encounter a form for the first time (an empty form), or we've submitted a form incorrectly, thus bringing us to the form in an incomplete state. The way views determine whether a user is seeing a form for the first time is by looking at the HTTP method sent with the user's request for the page.

Before we worry about logic related to form validation and submission, let's start comfortably by just serving a page with a form on it. Users won't be able to submit it just yet:

from django.shortcuts import render
from django.contrib import messages
from django.http import HttpResponseRedirect

from .forms import GuestBookForm


def form_template_view(request):
    """Handle forms in a function-based view."""
    form = GuestBookForm()
    context = {'title': 'Form View',
               'form': form,
               'path': request.path}
    return render(request, 'function_views/form.html', context)
views.py

Any time you visit a webpage via your browser, you're making a GET request. If users visiting our guestbook page have the GET method as request.method, we know they've just arrived and should be served a blank form. If that user were to submit said form, the form would execute a POST request on the same view, which is how we know that these users are hitting our view as the result of a form submission attempt. With that knowledge, we can expand on view to handle these two use cases:

...

@require_http_methods(["GET", "POST"])
def form_template_view(request):
    """Handle forms in a function-based view."""
    if request.method == 'POST':
        ...
    else:
        form = GuestBookForm()
    context = {'title': 'Form View',
               'form': form,
               'path': request.path}
    return render(request, 'function_views/form.html', context)
views.py

We've added a single if statement to see if we're dealing with a submission or not.

Now it's time for the magic. The first thing that happens in our if request.method == 'POST': block is a check to see if the form is valid by comparing the user's submitted form values to the validators we put in place when we created forms.py.

We'll assume the form was filled correctly, which brings us to the miracle of life itself: data creation. We create a new record in our database by extracting data from each of our form's fields and passing them into our Message model ( Message.objects.create() ). Each "field" we set in our model is defined via positional arguments in .create(), where we pass the name and message from our form:

...

from .models import Message


@require_http_methods(["GET", "POST"])
def form_template_view(request):
    """Create data records via form submission."""
    if request.method == 'POST':
        form = GuestBookForm(request.POST)
        if form.is_valid():
            Message.objects.create(name=form.cleaned_data.get('name'),
                                   message=form.cleaned_data.get('msg'))
            messages.success(request, 'Success!')
            return HttpResponseRedirect('form')
    else:
        form = GuestBookForm()
    context = {'title': 'Form View',
               'form': form,
               'path': request.path,
               'entries': Message.objects.all()}
    return render(request, 'function_views/form.html', context)
views.py

Last but not least, we add one more key/value pair to our context dictionary called entries, like so:

{ ...
 'entries': Message.objects.all()}
Fetch messages

Calling [MODEL].objects.all() will return all database records associated with the given model. Here we're passing all messages that users have left in our guestbook, so that we can display them to users. Check out the working version for yourself, but try to keep your comments nice as I have very fragile emotions: https://django.hackersandslackers.app/function_views/form/

View #4: Dynamic URLs

In the days of ye olde internet, we mostly referred to entities on the web as websites as opposed to web apps. If there's a distinction to be made here, I'd argue that the term website implied a certain expectation of functionality/structure: websites were typically structured as defined sets of pages that served dynamic content. Web apps contrast this by flipping the paradigm: the structure of a web "app" is typically informed by the data layer itself, which heightens web applications to be a reflection of the data which drives them.

An obvious example of this would be the concept of user profile pages. The taxonomy of a social network is entirely dependent on the users who have created profiles, groups, and all the other things which make a social network a social network. If we were to launch an identical clone of Facebook's codebase with ten active users per month, it would be a drastically different entity than the Facebook we know with 2.5 billion active users per month.

To illustrate the concept of apps with page structures determined by data, I threw together a fake social network of my own. It currently has 11 users, each of whom  happen to be amongst the greatest musical artists of the 21st century:

Navigating pages with dynamic URLs.

Creating Dynamic URL Patterns

Why is this important, and why am I showing this to you? The magic lays within the concept of dynamic URL patterns. User profile URLs in the above example are structured as /users/1/ /users/2/ and so forth. The number in this URL represents the user's assigned user ID. To see how this URL pattern differs from the ones we've created thus far, let's take a look at urls.py:

from django.urls import path

from . import views

urlpatterns = [
    # URL patterns of views we've created thusfar
    path('get_json/', views.get_json_view, name='get_json'),
    path('get_template/', views.get_template_view, name='get_template'),
    path('form/', views.form_template_view, name='form'),
    
    # URL patterns to handle dynamic pages
    path('users/', views.user_profile_list_view, name='users'),
    path('users/<int:user_id>/', views.user_profile_view),
]
urls.py

Unlike our other URLs, the last URL pattern is able to accept a dynamic value with 'users/<int:user_id>/'. The presence of <int:user_id> tells Django to honor any integer in our URL, which means visiting a URL such as /users/4563988/ would be perfectly valid. But what if we don't have a user with an ID equal to 4563988? I'm glad you asked - let's see what's happening in user_profile_view() in views.py.

Resolving Views with Dynamic Values

Unlike views we defined previously, user_profile_view() now accepts a second parameter, which happens to be user_id being passed in through our URL pattern:

...
from .models import User
from django.shortcuts import get_object_or_404


@require_http_methods(["GET"])
def user_profile_view(request, user_id):
    """User profile page."""
    user = get_object_or_404(User, id=user_id)
    context = {'user': user,
               'title': f'{user.name}\'s Profile',
               'path': request.path}
    return render(request, 'function_views/profile.html', context)
views.py

Our view is surprisingly simple thanks in part to a magic function called get_object_or_404(). This function allows us to query a data model for a single record, similar to calling User.objects.get(id=user_id). The difference between fetching a record directly via the User model versus get_object_or_404() is the latter method instantly serves the user a 404 if record does not exist:

# get_object_or_404([MODEL], [FIELD]=[VALUE])
get_object_or_404(User, id=user_id)
get_object_or_404()

The above is how we know which user's profile to serve at a given URL. The sequence of events looks like this:

  • A user navigates to the URL /users/1/.
  • Our URL pattern passes 1 to our view, user_profile_view().
  • user_profile_view() attempts to fetch a user from the database matching an ID of 1.
  • If a user matches the given ID, the record for that user is passed to our profile template to generate a frontend profile for Cardi B.
  • If no user matches the given ID, Django serves a 404 error.

Check out the functionality in action for yourself: https://django.hackersandslackers.app/function_views/users/

View #5: Query String Parameters

A great way to pass contextual information to views is through the use of query string parameters. Let's say we wanted a way to filter the list of users in our users view to only display users matching a certain criteria. An easy solution would be the ability to append arguments to our path like so:

http://127.0.0.1:8000/function_views/users/?profession=rapper
URL query string parameter

Fetching parameters in a view is actually quite easy. Parameters are stored in the request object, specifically for GET requests. The syntax for fetching a parameter by name from our URL looks like this:

param = request.GET.get('parameter_name')
Fetch query string parameters from request

The above checks to see if a parameter called parameter_name was appended to the URL submitted by the user. If so, our variable will be equal to the value assigned to parameter_name.

We're going to modify our user list view to look for a parameter named profession in the URL. If it exists, we'll filter the results by whichever profession value was provided. Otherwise, we'll just display all users:

...


@require_http_methods(["GET", "POST"])
def user_profile_list_view(request):
    """Directory of user profiles."""
    user_profession = request.GET.get('profession', None)
    if user_profession:
        users = User.objects.filter(profession=user_profession)
    else:
        users = User.objects.all()
    context = {'title': 'User Profile Directory',
               'users': users,
               'path': request.path}
    return render(request, 'function_views/users.html', context)
views.py

Let's see it in action:

Filtering a list via a query string.

I Need to Pass Out Now

When I first decided to write an intro tutorial to Django views, I never would have expected it to take over a month of effort and 5000 words of jargon. This underscores a fact I thought I fully understood: Django is not an easy framework to deal with by today's standards. There are a lot of things I was forced to skim over in this tutorial, which is something that pains me to do. Don't blame yourself if some of this stuff hasn't clicked, because it isn't your fault. I'm here to help - that's what the comments section is for :).

hackersandslackers/django-views-tutorial
:rocket: :white_check_mark: Function and class-based Django view repository. - hackersandslackers/django-views-tutorial