Hackernoon logoDjango Tutorials: Building a Web App from Scratch by@vladyslav

Django Tutorials: Building a Web App from Scratch

vladyslav nykoliuk Hacker Noon profile picture

@vladyslavvladyslav nykoliuk

software engineer @ planet earth 🌍

Welcome to what I hope will be a very detailed and useful tutorial on building a Django web app from scratch to production. Having developed dozens of Django projects prior, I have acquired certain tips and tricks to increase efficiency in each Django project that I will present in the form of a tutorial. This tutorial is a step-by-step process of how I go about building robust Django applications. Enjoy!

You can check out the deployment here: Live Link

Demo:

Part 1 πŸš€

For this sample project, we'll go a little beyond a simple Todo app or Blog site - we will build out a food recipe app with full user authentication, CRUD features, and deploy the app to live production on Heroku.

The Directory

The first step in any new project is setting up the directory. If you would like your Django project in a specific directory, navigate to it before running the startproject command. Create a new Django project with the following command:

django-admin startproject [projectname]

This should generate a file structure as such:

β”œβ”€ foodanic (our sample project title)
β”‚  β”œβ”€ __init__.py
β”‚  β”œβ”€ asgi.py
β”‚  β”œβ”€ settings.py
β”‚  β”œβ”€ urls.py
β”‚  β”œβ”€ wsgi.py
β”œβ”€ manage.py

Let's quickly add a folder titled

templates
into the directory with foodanic/ and manage.py

The Environment

The next crucial step is a virtual environment to contain all our dependencies in one module.

To create a new virtual environment:

virtualenv env

Note: the [env] can be anything you want to name your virtual environment

To activate the environment:

source env/bin/activate

To deactivate the environment:

deactivate 

After you create and activate the environment, an (env) tag will appear in your terminal next to your directory name.

The Settings:

This is a step you have to remember for all future projects because a proper initial settings setup will prevent bugs in the future.

In your

settings.py
file, at the top, add
import os
next scroll down to the
TEMPLATES
section and make the following change in
DIRS
:

import os

'DIRS': [os.path.join(BASE_DIR, 'templates')],

This allows you to forward the root template of the project to the main templates directory, for future reference to the

base.html
file.

While we're at it, let's install Django into our app with:

pip install django

Next, we will install a middleware that helps Heroku process images for Django applications called

whitenoise
.

To install the dependency, run:

pip install whitenoise

Add whitenoise to your

MIDDLEWARE
:

# settings.py
MIDDLEWARE = [
   ...
   'whitenoise.middleware.WhiteNoiseMiddleware',
]

Every time we add a new dependency to the project, you'll want to freeze them to a file called

requirements.txt
.

To do this run:

pip freeze > requirements.txt

Static and Media

Static and media will serve the images on our app. Below the defined

STATIC_URL
in the settings.py, add

#settings.py

STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
STATIC_TMP = os.path.join(BASE_DIR, 'static')
STATICFILES_DIRS = (
    os.path.join(BASE_DIR, 'static'),
)
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'

MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
os.makedirs(STATIC_TMP, exist_ok=True)
os.makedirs(STATIC_ROOT, exist_ok=True)
os.makedirs(MEDIA_ROOT, exist_ok=True)

This sets up our static and media directories in the most optimal way to serve our app.

.gitignore

Another important step in starting a Django project is the .gitignore file which will ignore the directories/files listed there.

Create a .gitignore with:

touch .gitignore

Let's add the virtual environment we created to it so it doesn't take up extra cloud space on Github.

# .gitignore
env/

Part 2 🚲

Now that we have our project set up the way we want, let's begin by creating our first app to handle the logic. Along with that, let's also create a users app that we will use for User Authentication in a moment.

Create a new app with:

python manage.py startapp app
python manage.py startapp users

Add the app to settings.py:

# settings.py
INSTALLED_APPS = [
    'app',
    'users',
     ...
]

Now in order for our apps to be routed properly in our web app, we need to include our other apps in the main

foodanic/urls.py
.

# foodanic/urls.py

from django.contrib import admin
from django.urls import path, include
from django.conf.urls.static import static
from django.conf import settings

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('app.urls')),
    path('u/', include('users.urls')),
]

if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root = settings.MEDIA_ROOT)

Apps

Inside the new app and users directory, let's add two files and two folders.

Files to add:         Folders to add:  (for each respectively)
- urls.py             - app/templates/app       
- forms.py            - users/templates/users

The new app and users directory will look like this:

β”œβ”€ app
β”‚  β”œβ”€ migrations/
|  β”œβ”€ templates
|  |  └── app/
β”‚  β”œβ”€ __init__.py
β”‚  β”œβ”€ admin.py
β”‚  β”œβ”€ apps.py
β”‚  β”œβ”€ forms.py
β”‚  β”œβ”€ models.py
β”‚  β”œβ”€ tests.py
β”‚  β”œβ”€ urls.py
β”‚  └── views.py
β”‚
β”œβ”€ users
β”‚  β”œβ”€ migrations/
|  β”œβ”€ templates
|  |  └── users/
β”‚  β”œβ”€ __init__.py
β”‚  β”œβ”€ admin.py
β”‚  β”œβ”€ apps.py
β”‚  β”œβ”€ forms.py
β”‚  β”œβ”€ models.py
β”‚  β”œβ”€ tests.py
β”‚  β”œβ”€ urls.py
β”‚  └── views.py

User Authentication

For our convenience, we will use the basic Django built-in authentication system.

In our settings.py we will need to specify a login and logout redirect like so:

# foodanic/settings.py

...

LOGIN_REDIRECT_URL = '/'
LOGOUT_REDIRECT_URL = '/'
LOGIN_URL = 'login'

In our newly created Users app, head to urls to include the Django auth views.

# users/urls.py
from django.urls import path
from django.conf.urls.static import static
from django.conf import settings
from django.contrib.auth import views as auth_views
from .views import *

urlpatterns = [
    path('signup/', signup, name='signup'),
    path('login/', auth_views.LoginView.as_view(template_name='users/login.html'), name='login'),
    path('logout/', auth_views.LogoutView.as_view(template_name='users/logout.html'), name='logout'),
    path('change-password/', auth_views.PasswordChangeView.as_view(template_name='users/change-password.html'), name="change-password"),
    path('password_change/done/', auth_views.PasswordChangeDoneView.as_view(template_name='users/password_reset_done.html'), name='password_change_done'),
    path('password_reset/', auth_views.PasswordResetView.as_view(template_name='users/forgot-password.html', subject_template_name='users/password_reset_subject.txt', html_email_template_name='users/password_reset_email.html'), name='password_reset'),
    path('password_reset/done/', auth_views.PasswordResetDoneView.as_view(template_name='users/password_reset_done.html'), name='password_reset_done'),
    path('reset/<uidb64>/<token>/', auth_views.PasswordResetConfirmView.as_view(template_name='users/password_reset_confirm.html'), name='password_reset_confirm'),
    path('reset/done/', auth_views.PasswordResetCompleteView.as_view(template_name='users/password_reset_complete.html'), name='password_reset_complete'),
]

if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root = settings.MEDIA_ROOT)

Now that was a ton of new urls we just added, let's make sure we have the templates needed. The line below will create all the necessary templates, given you're in the base directory (foodanic).

touch users/templates/users/login.html && touch users/templates/users/logout.html && touch users/templates/users/change-password.html && touch users/templates/users/password_reset_done.html && touch users/templates/users/forgot-password.html && touch users/templates/users/password_reset_done.html && touch users/templates/users/password_reset_confirm.html && touch users/templates/users/password_reset_complete.html && touch users/templates/users/password_reset_email.html && touch users/templates/users/password_reset_subject.txt && touch users/templates/users/signup.html && touch users/templates/users/style.html

Now we can setup each template to render from the base and display the corresponding form. Credit to this Codepen for the bootstrap design.

users/style.html

<style>
    html,body { 
        height: 100%; 
    }
    
    .global-container{
        height:100%;
        display: flex;
        align-items: center;
        justify-content: center;
        /* background-color: #f5f5f5; */
    }
    
    form{
        padding-top: 10px;
        font-size: 14px;
        margin-top: 30px;
    }
    
    .card-title{ font-weight:300; }
    
    .btn{
        font-size: 14px;
        margin-top:20px;
    }
    
    
    .login-form{ 
        width:330px;
        margin:20px;
    }
    
    .sign-up{
        text-align:center;
        padding:20px 0 0;
    }
    
    .alert{
        margin-bottom:-30px;
        font-size: 13px;
        margin-top:20px;
    }
</style>

users/login.html

<!-- users/login.html -->

{% extends 'base.html' %}

{% block content %}
{% load crispy_forms_tags %}

<br class="mt-0 mb-4">
<div class="container">
    <div class="global-container">
        <div class="card login-form">
            <div class="card-body">
                <h3 class="card-title text-center">Log in to Foodanic</h3>
                <div class="card-text">
                    <form method="POST">{% csrf_token %}
                        <div class="form-group">
                            <label for="username">Username</label>
                            <input type="text" name="username" class="form-control form-control-sm" id="username" aria-describedby="emailHelp">
                        </div>
                        <div class="form-group">
                            <label for="password">Password</label>
                            <a href="{% url 'password_reset' %}" style="float:right;font-size:12px;text-decoration:none;">Forgot password?</a>
                            <input type="password" name="password" class="form-control form-control-sm" id="password">
                        </div>
                        <button type="submit" class="btn btn-primary btn-block">Sign in</button>
                        
                        <div class="sign-up">
                            Don't have an account? <a href="{% url 'signup' %}" style="text-decoration:none;">Create One</a>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>

{% include 'users/style.html' %}

{% endblock %}

users/logout.html

<!-- users/logout.html -->
{% extends 'base.html' %}

{% block content %}

<div class="container justify-content-center">
    <h4>You have successfully logged out of Foodanic. <a href="{% url 'login' %}" style="text-decoration:none;">Log back in -></a></h4>
</div>

{% endblock %}

users/signup.html

<!-- users/signup.html -->

{% extends 'base.html' %}

{% block content %}
{% load crispy_forms_tags %}

<br class="mt-0 mb-4">
<div class="container">
    <div class="global-container">
        <div class="card login-form">
            <div class="card-body">
                <h3 class="card-title text-center">Signup for Foodanic</h3>
                <div class="card-text">
                    <form method="POST">{% csrf_token %}
                        {{ form|crispy }}
                        <button type="submit" class="btn btn-primary btn-block">Sign Up</button>
                        
                        <div class="sign-up">
                            Already have an account? <a href="{% url 'login' %}" style="text-decoration:none;">Sign In</a>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>

{% include 'users/style.html' %}

{% endblock %}

users/change-password.html

<!-- users/change-password.html -->

{% extends 'base.html' %}

{% block content %}
{% load crispy_forms_tags %}

<br class="mt-0 mb-4">
<div class="container">
    <div class="global-container">
        <div class="card login-form">
            <div class="card-body">
                <h3 class="card-title text-center">Log in to Foodanic</h3>
                <div class="card-text">
                    <form method="POST">{% csrf_token %}
                            {{ form|crispy }}
                        <button type="submit" class="btn btn-primary btn-block">Update Password</button>
                    </form>
                </div>
            </div>
        </div>
    </div>
</div>


{% include 'users/style.html' %}

{% endblock %}

users/password_reset_done.html

<!-- users/password_reset_done.html -->

{% extends 'base.html' %}

{% block title %}Email Sent{% endblock %}

{% block content %}
<br><br>
<div class="container">
  <h1>Check your inbox.</h1>
  <p>We've emailed you instructions for setting your password. You should receive the email shortly!</p>
  <button class="btn btn-primary"><a href="{% url 'home' %}">Return Home</button></a>
</div>
{% endblock %}

users/forgot-password.html

<!-- users/forgot-password.html -->

{% extends 'base.html' %}
{% block content %}
{% load static %}
{% load crispy_forms_tags %}

<body class="bg-gradient-primary">

  <div class="container">

    <div class="row justify-content-center">

      <div class="col-xl-10 col-lg-12 col-md-9">

        <div class="card o-hidden border-0 shadow-lg my-5">
          <div class="card-body p-0">
            <div class="row">
              <div class="col-lg-6 d-none d-lg-block bg-password-image">
                <img src="https://i.imgur.com/ryKdO1v.jpg" style="width: 100%; height: 100%;" alt="">
              </div>
              <div class="col-lg-6">
                <div class="p-5">
                  <div class="text-center">
                    <h1 class="h4 text-gray-900 mb-2">Forgot Your Password?</h1>
                    <p class="mb-4">We get it, stuff happens. Just enter your email address below and we'll send you a link to reset your password!</p>
                  </div>
                  <form class="user" method="POST">
                    {% csrf_token %}
                    <div class="form-group" style="border: 2px gray;">
                      <!-- {{ form|crispy }} -->
                      <input type="email" name="email" class="form-control form-control-user" id="exampleInputEmail" aria-describedby="emailHelp" placeholder="Enter your email...">
                    </div>
                    <br>
                    <button class="btn btn-primary btn-user btn-block" type="submit" style="text-decoration: none;">
                      Reset Password
                    </button>
                  </form>
                  
                  <hr>
                  <div class="text-center">
                    <a class="small" href="{% url 'signup' %}" style="text-decoration: none;">Create an Account!</a>
                  </div>
                  <div class="text-center">
                    <a class="small" href="{% url 'login' %}" style="text-decoration: none;">Already have an account? Login!</a>
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
{% endblock %}

users/password_reset_subject.txt

Foodanic Password Reset

users/password_reset_email.html

<!-- users/password_reset_email.html -->

{% autoescape off %}

Hi, {{ user.username }}.
<br><br>
We received a request for a password reset. If this was you, 
follow the link below to reset your password. If this wasn't you, no action is needed.
<br><br>
<a href="{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}" target="_blank">{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}</a>
<br><br>
If clicking the link above doesn't work, please copy and paste the URL in a new browser
window instead.
<br><br>
Sincerely,<br>
Foodanic
{% endautoescape %}

users/password_reset_done.html

{% extends 'base.html' %}

{% block title %}Email Sent{% endblock %}

{% block content %}
<br><br>
<div class="container">
  <h1>Check your inbox.</h1>
  <p>We've emailed you instructions for setting your password. You should receive the email shortly!</p>
  <button class="btn btn-primary"><a href="{% url 'home' %}">Return Home</button></a>
</div>
{% endblock %}

password_reset_confirm.html

{% extends 'base.html' %}

{% block title %}Enter new password{% endblock %}
{% load crispy_forms_tags %}
{% block content %}

{% if validlink %}
<br><br>
<div class="container">
  <h1>Set a new password</h1>
  <form method="POST">
    {% csrf_token %}
    {{ form|crispy }}
    <br>
    <button class="btn btn-primary" type="submit">Change my password</button>
  </form>
</div>
{% else %}

<p>The password reset link was invalid, possibly because it has already been used. Please request a new password reset.</p>

{% endif %}
{% endblock %}

users/password_reset_complete.html

{% extends 'base.html' %}

{% block title %}Password reset complete{% endblock %}

{% block content %}
<br><br>
<div class="container">
    <h1>Password reset complete</h1>
    <p>Your new password has been set. You can now <a href="{% url 'login' %}" style="text-decoration: none;">log in</a>.</p>
</div>
{% endblock %}

Now you can give our new user authentication a try, which will take you on a tour of the Django Authentication System. Keep in mind, the Password Reset won't work because we didn't set up the email server with Django. I recommend this tutorial for assisting you with email reset setup.

If you would like to create an admin account for your site, you can do so with:

python manage.py createsuperuser

Main App

Here is where the fun part comes, we will build out the CRUD operations of the app.

Views

The views control the logic of the app, rendering out functions and performing necessary operations on forms, templates, and anything else having to do with your app.

First, we will write out the functions we will be working on.

# views.py

from django.shortcuts import render

def home(request):
    context = {}
    return render(request, 'app/index.html', context)

def detail(request, id):
    context = {}
    return render(request, 'app/detail.html', context)

def create(request):
    context = {}
    return render(request, 'app/create.html', context)

def update(request, id):
    context = {}
    return render(request, 'app/update.html', context)

def delete(request, id):
    context = {}
    return render(request, 'app/delete.html', context)

Next, let's add them to the

urls.py
file in app in addition to the media url and root to handle our future images:

# app/urls.py
from django.urls import path
from .views import *
from django.conf.urls.static import static
from django.conf import settings

urlpatterns = [
    path('', home, name='home'),
    path('detail/<int:id>/', detail, name='detail'),
    path('new/', create, name='create'),
    path('update/<int:id>/', update, name='update'),
    path('delete/<int:id>/', delete, name='delete'),
]

if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root = settings.MEDIA_ROOT)

In order for our urls in app to work properly, we need to add them to our main urls.py file. In addition, also add the media url and root to the main urls as well.

# foodanic/urls.py
from django.contrib import admin
from django.urls import path, include
from django.conf.urls.static import static
from django.conf import settings

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('app.urls')),
    path('u/', include('users.urls')),
]

if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root = settings.MEDIA_ROOT)

Run migrations and server

Now we are ready to start developing our web application. Let's run migrations to create an initial database and run our app.

Run migrations with:

python manage.py migrate

Run server with:

python manage.py runserver [OPTIONAL: PORT]

*Note: The optional port can be used as such:

python manage.py runserver 8000
python manage.py runserver 1234

Model

Now we can set up our model that will store each recipe.

In models.py add the following code:

# models.py
from django.db import models
from datetime import datetime, timedelta
from markdownx.models import MarkdownxField
from django.contrib.auth.models import User

class Recipe(models.Model):
    name = models.CharField(max_length=255)
    description = models.TextField()
    prep = models.CharField(max_length=255)
    cook = models.CharField(max_length=255)
    servings = models.IntegerField(default=1, null=True, blank=True)
    image = models.ImageField(upload_to='media/')
    ingredients = MarkdownxField()
    directions = MarkdownxField()
    notes = models.TextField(null=True, blank=True)
    author = models.ForeignKey(User, on_delete=models.CASCADE)

    def __str__(self):
        return self.name

    @property
    def formatted_ingredients(self):
        return markdownify(self.ingredients)

    @property
    def formatted_directions(self):
        return markdownify(self.directions)

A few things to note here: we have 9 fields that will hold the information for the Recipe model. We will be using Django MarkdownX (Github Link) for two of the fields for a nicer look. The

@property
creates a property tag we can use in our templates to render out Markdown fields.

To install Django Markdown, run:

pip install django-markdownx

Add it to settings.py:

# settings.py

INSTALLED_APPS = [
   'markdownx',
   ...
]

Add it to requirements with:

pip freeze > requirements.txt

Add it to main urls.py:

# foodanic/urls.py

urlpatterns = [
    path('markdownx/', include('markdownx.urls')),
]

Now that we have our model set up, we can go ahead and run migrations. Note: You must run migrations with every change you make to models so the database gets updated.

python manage.py makemigrations && python manage.py migrate

If everything went well, you should see an output similar to this and a brand new migration in your app

migrations/
folder.

(env) ➜  foodanic git:(master) βœ— python manage.py makemigrations && python manage.py migrate
Migrations for 'app':
  app/migrations/0001_initial.py
    - Create model Recipe
Operations to perform:
  Apply all migrations: admin, app, auth, contenttypes, sessions
Running migrations:
  Applying app.0001_initial... OK
(env) ➜  foodanic git:(master) βœ— 

In order for our model to successfully be displayed in the Django Admin, we need to register it to the admin.py file like so:

# app/admin.py
from django.contrib import admin
from .models import *

admin.site.register(Recipe)

Main Form

In order to pass data into our database swiftly, we will use a django ModelForm based on our model.

Make the following changes in your forms file:

# app/forms.py
from django import forms
from .models import *
from durationwidget.widgets import TimeDurationWidget

class RecipeForm(forms.ModelForm):
    prep = forms.DurationField(widget=TimeDurationWidget(show_days=False, show_hours=True, show_minutes=True, show_seconds=False), required=False)
    cook = forms.DurationField(widget=TimeDurationWidget(show_days=False, show_hours=True, show_minutes=True, show_seconds=False), required=False)
    
    class Meta:
        model = Recipe
        fields = '__all__'
        exclude = ('author',)

With this form, we will be able to render out all the fields in the Recipe model. Additionally, if you wanted to only include certain fields you would list them in an array as such:

fields = ['name', 'image',]
or if you wanted to exclude certain fields, you would list them as such:
exclude = ('name', 'image',)
.

You might have noticed we added a new library to help us render the duration field for prep and cook time. Also, let's install another module we will use later to help us with the form, Django Crispy Forms.

Install it with pip:

pip install django-durationwidget
pip install django-crispy-forms

Add it to settings:

# settings.py
INSTALLED_APPS = [
    'durationwidget',
    'crispy_forms',
]

TEMPLATES = [
    'APP_DIRS': True,        # set to True
]

# on the bottom of settings.py
CRISPY_TEMPLATE_PACK = 'bootstrap4'

And let's freeze the requirements to save the dependency:

pip freeze > requirements.txt

CRUD

Now we are ready to start writing the logic of our views.

Let's begin with the __C__ in __CRUD__ which stands for (Create, Read, Update, Delete)

Create

In our views, let's import the forms, the models and render out the form for a GET and POST request. The GET request will render when a user goes on the page to create a new recipe whereas the POST will handle the form logic once submitted.

# app/views.py
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required
from django.core.files.storage import FileSystemStorage
from datetime import datetime, timedelta
from .models import *
from .forms import *

@login_required
def create(request):
    context = {}
    if request.method == 'GET':
        form = RecipeForm()
        context['form'] = RecipeForm()
        return render(request, 'app/create.html', context)
    elif request.method == 'POST' and request.FILES != None:
        form = RecipeForm(request.POST, request.FILES)
        if form.is_valid():
            new = Recipe()
            user = request.user
            new.author = user
            new.name = form['name'].value()
            new.description = form['description'].value()
            new.prep = form['prep'].value()
            new.cook = form['cook'].value()
            new.servings = form['servings'].value()
            new.ingredients = form['ingredients'].value()
            new.directions = form['directions'].value()
            new.notes = form['notes'].value()
            theimg = request.FILES['image']
            fs = FileSystemStorage()
            filename = fs.save(theimg.name, theimg)
            file_url = fs.url(filename)
            new.image = filename
            new.save()
            return redirect('home')
        else:
            form = RecipeForm()
            context['form'] = RecipeForm()
            return render(request, 'app/create.html', context)
    return render(request, 'app/create.html', context)

Woah, that's a whole lotta code - let's break it down so we understand what it's doing.

The if statement handles the logic of which template to render if GET and where to redirect the user after the submission, POST. The

request.FILES
in the form is for our image field. Essentially, if the form submitted passes our parameters, we create a new instance of the Recipe model and save the contents of the form to the model values respectively.

Now we have to render out a template for the form. To do that, we will need to create a

base.html
file in our base templates. I'll add the most up-to-date version of Bootstrap which is 5 - so if you're reading this tutorial later on, be sure to update the corresponding CDN for Bootstrap, found at getbootstrap.com.

foodanic/templates/base.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Foodanic</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-BmbxuPwQa2lc/FVzBcNJ7UAyJxM6wuqIj61tLrc4wSX0szH/Ev+nYRRuWlolflfl" crossorigin="anonymous">
    <link rel="shortcut icon" href="https://media.istockphoto.com/vectors/hand-opening-silver-cloche-vector-id1135322593?k=6&m=1135322593&s=612x612&w=0&h=QhIjVZdKyGzfQ6aGojvSFgXpLZpEG7RsueYSLngbdLA=" type="image/x-icon">
</head>
<body>
    {% block content %}

    {% endblock %}
    
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta2/dist/js/bootstrap.bundle.min.js" integrity="sha384-b5kHyXgcpbZJO/tY9Ul7kGkf1S0CWuKcCD38l8YkeH8z8QjE0GmW1gYU5S9FOnJ0" crossorigin="anonymous"></script>
</body>
</html>

Now we have our base.html setup, and we can render other templates without the unnecessary content. I Bootstrapped the

create.html
page to a passable format but feel free to change up the design however you please.

app/create.html

{% extends 'base.html' %}
{% block content %}
{% load crispy_forms_tags %}


<br class="mt-0 mb-4">
<div class="container">
    <h4>New Recipe</h4>
    <p><i>Note: The Ingredients and Directions fields are Markdown Supported. Learn more about markdown <a href="https://www.markdownguide.org/cheat-sheet/" target="_blank" style="text-decoration: none;">here</a>.</i></p>
    <br>
        <form method="post" enctype="multipart/form-data">
            {% csrf_token %}
            <div class="row">
                <div class="col-6">
                    <div class="col">
                        {{ form.name|as_crispy_field }}
                        {{ form.image|as_crispy_field }}
                    </div>
                </div>
                <div class="col-6">
                    {{ form.description|as_crispy_field }}
                </div>
            </div>
            <br>
            <div class="row justify-content-center">
                <div class="col-2">
                    {{ form.prep|as_crispy_field }}
                </div>
                <div class="col-2">
                    {{ form.cook|as_crispy_field }}
                </div>
                <div class="col-2">
                    {{ form.servings|as_crispy_field }}
                </div>
            </div>
            <br>
            <div class="row">
                <div class="col-4">
                    {{ form.ingredients|as_crispy_field }}
                </div>
                <div class="col-4">
                    {{ form.directions|as_crispy_field }}
                </div>
                <div class="col-4">
                    {{ form.notes|as_crispy_field }}
                </div>
            </div>
            <div class="mt-4 mb-4 d-flex justify-content-center">
                <button type="submit" class="btn btn-success">Post Recipe</button>
            </div>
        </form>
   
    {{ form.media }}
</div>

{% endblock %}   

In the beginning, you can see we render the info in block content tags based on the

base.html
file we created. We load crispy in with the tag and set each field as a crispy field. The
{{ form.media }}
tag renders out the content for the MarkdownX fields. Alternatively, you can render out the entire form as crispy like this:
{{ form|crispy }}
.

The new route should look something like this:

Read

The read portion of CRUD has to do with being able to view each individual object in our database. First, we'll do single recipes, then we'll pull the entire set for our index page.

app/views.py

from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required
from django.core.files.storage import FileSystemStorage
from datetime import datetime, timedelta
from markdownx.utils import markdownify   # new
from .models import *
from .forms import *

def home(request):
    recipes = Recipe.objects.all()
    context = {
        'recipes': recipes,
    }
    return render(request, 'app/index.html', context)

def detail(request, id):
    recipe = get_object_or_404(Recipe, id=id)
    recipe.ingredients = markdownify(recipe.ingredients)
    recipe.directions = markdownify(recipe.directions)

    context = {
        'recipe': recipe,
    }
    return render(request, 'app/detail.html', context)

The template below is credited to user ARIELOZAM on Codepen.

app/detail.html

{% extends 'base.html' %}
{% block content %}
{% load crispy_forms_tags %}

<br class="mt-0 mb-4">
<div class="container">
    <div class="bg-codeblocks">
        <div class="main-box-codeblocks">
            <div class="container">
                <div class="row">
                    <div class="col-md-12">
                        <a href="{% url 'home' %}"><button class="btn btn-info mb-4">Back Home</button></a>
                    </div>
                </div>
                <div class="row">
                    <div class="col-md-6">
                        <div class="box-image-codeblocks">
                            <div class="swiper-container gallery-top">
                                <div class="swiper-wrapper">
                                    <div class="swiper-slide">
                                        <div class="product-image">
                                            <img src="{{recipe.image.url}}" alt="{{recipe.name}}" class="img-fluid" style="width: 650px; height: 100%;">
                                        </div>
                                    </div>
                                </div>
                            </div>
                        </div>
                    </div>
                    <div class="col-md-6">
                        <h2 class="text-bold text-strong">{{ recipe.name|capfirst }} Recipe  {% if user.is_authenticated and request.user == recipe.author %}   <a href="{% url 'update' recipe.id %}"><i class="fas fa-edit"></i></a> <span data-bs-toggle="modal" data-bs-target="#delete"><i class="fas fa-trash"></i></span> {% endif %}</h2>
                        <span class="seller-name-codeblocks">
                            <h5>by <a href="#" style="text-decoration: none;">{{recipe.author}}</a></h5>
                        </span>
                        <br>
                        <span class="description-codeblocks">
                            <p>
                                <strong>Description:</strong> <br>
                                <span class="text-muted">
                                    <p style="width: 450px;overflow:scroll;">{{recipe.description}}</p>
                                </span>
                            </p>
                        </span>
                        <br>
                        <span class="extras-codeblocks ">
                            <ul class="nav nav-tabs my-2" id="myTab" role="tablist">
                                <li class="nav-item" role="presentation">
                                    <a class="nav-link active" id="home-tab" data-toggle="tab" href="#home" role="tab" aria-controls="home" aria-selected="true">Quick Info</a>
                                </li>
                                <li class="nav-item" role="presentation">
                                    <a class="nav-link" id="ingredients-tab" data-toggle="tab" href="#ingredients" role="tab" aria-controls="ingredients" aria-selected="false">Ingredients</a>
                                </li>
                                <li class="nav-item" role="presentation">
                                    <a class="nav-link" id="directions-tab" data-toggle="tab" href="#directions" role="tab" aria-controls="directions" aria-selected="false">Directions</a>
                                </li>
                            </ul>
                            <div class="tab-content" id="myTabContent">
                                <div class="tab-pane fade show active" id="home" role="tabpanel" aria-labelledby="home-tab">
                                    <br>
                                    <table style="width:250px;">
                                        <tr>
                                            <th>Servings:</th>
                                            <td>{{ recipe.servings }}</td>
                                        </tr>
                                        <tr>
                                            <th>Prep:</th>
                                            <td>{{ recipe.prep }}</td>
                                        </tr>
                                        <tr>
                                            <th>Cook:</th>
                                            <td>{{ recipe.cook }}</td>
                                        </tr>
                                    </table>
                                </div>
                                <div class="tab-pane fade" id="ingredients" role="tabpanel" aria-labelledby="ingredients-tab">
                                    {{ recipe.ingredients|safe }}
                                </div>
                                <div class="tab-pane fade" id="directions" role="tabpanel" aria-labelledby="directions-tab">
                                    {{ recipe.directions|safe }}
                                </div>
                            </div>
                        </span>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>
      
<!-- Modal -->
<div class="modal fade" id="delete" data-bs-backdrop="static" data-bs-keyboard="false" tabindex="-1" aria-labelledby="deleteLabel" aria-hidden="true">
    <div class="modal-dialog">
        <div class="modal-content">
        <div class="modal-header">
            <h5 class="modal-title" id="deleteLabel">Are you 100% sure?</h5>
            <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
        </div>
        <div class="modal-body">
            Are you absolutely sure you want to delete the {{recipe.name|capfirst}} Recipe? The data will be erased from the database and will not be retrievable.
        </div>
        <div class="modal-footer">
            <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Nevermind</button>
            <a href="{% url 'delete' recipe.id %}"><button type="button" class="btn btn-primary">OK, Proceed</button></a>
        </div>
        </div>
    </div>
</div>

<style>
.bg-codeblocks {
    margin-top: 4%;
    position: absolute;
    background: #8E2DE2;
    background: -webkit-linear-gradient(to right, #4A00E0, #8E2DE2);
    background: linear-gradient(to right, #4A00E0, #8E2DE2);
    height: auto;
}

.main-box-codeblocks	{
	background-color: #FAFAFA;
	border-radius: 20px;
	padding: 5em 2em;
	width:90%;
	height: auto;
	position: relative;
	display: block;
	box-shadow: 0 0px 20px 2px rgba(0,0,0,0.5);
	margin: 3em auto;
	
}
</style>

<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.5.3/js/bootstrap.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.2/css/all.min.css">

{% endblock %} 

The new template should look like this:

Now it's time to get all of our Recipes to display on our homepage.

app/index.html

{% extends 'base.html' %}
{% block content %}
{% load crispy_forms_tags %}

    <nav class="navbar navbar-expand-lg navbar-light bg-light">
        <div class="container-fluid">
          <a class="navbar-brand" href="{% url 'home' %}">Foodanic</a>
          <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
            <span class="navbar-toggler-icon"></span>
          </button>
          <div class="collapse navbar-collapse" id="navbarSupportedContent">
            <ul class="navbar-nav me-auto mb-2 mb-lg-0">
              <li class="nav-item">
                <a class="nav-link active" aria-current="page" href="{% url 'home' %}"><button class="btn btn-warning" style="color: white;">All Recipes</button></a>
              </li>
              <li class="nav-item">
                <a class="nav-link active" href="{% url 'create' %}"><button class="btn btn-info" style="color: white;">New Recipe</button></a>
              </li>
            </ul>
            {% if not request.user.is_authenticated %}
                <a class="nav-link active" aria-current="page" href="{% url 'login' %}"><button class="btn btn-dark" style="color: white;">Login</button></a>
            {% else %}
                <a class="nav-link active" aria-current="page" href="{% url 'logout' %}"><button class="btn btn-dark" style="color: white;">Logout</button></a>
            {% endif %}
          </div>
        </div>
      </nav>

  <div class="container">
    <header class="jumbotron my-4">
      <h1 class="display-3">A Warm Welcome!</h1>
      <p class="lead">Browse through our collection of various recipes.</p>
      <a href="{% url 'create' %}"><button class="btn btn-info btn-lg" style="color: white;">Post Your Recipe</button></a>
    </header>

    <br>
    <div class="row text-center">
    {% for recipe in recipes %}
      <div class="col-lg-4 col-md-6 mb-4">
        <div class="card h-100 w-75">
            <a href="{% url 'detail' recipe.id %}"><img class="card-img-top" src="{{recipe.image.url}}" alt="{{recipe.name}}"></a>
          <div class="card-body">
            <h4 class="card-title"><a href="{% url 'detail' recipe.id %}" style="text-decoration: none;">{{recipe.name}} Recipe</a></h4>
            <p class="card-text">{{recipe.description|truncatechars:65}}</p>
            <p><b>Prep Time: </b>{{recipe.prep}} <br>
               <b>Cook Time: </b>{{recipe.cook}}
            </p>
          </div>
          <div class="card-footer">
            <a href="{% url 'detail' recipe.id %}" class="btn btn-primary">View</a>
          </div>
        </div>
      </div>
    {% endfor %}
    </div>
  </div>

  <br><br><br>
  <footer class="py-5 bg-dark">
    <div class="container">
      <p class="m-0 text-center text-white">Copyright &copy; Foodanic 2021</p>
    </div>
  </footer>
{% endblock %}

With a few sample Recipes, here is how the Home Page comes out to:

Update

The update view will grab an instance of the object we want to update and save the new information.

app/views.py

@login_required
def update(request, id):
    recipe = get_object_or_404(Recipe, id=id)
    context = {
        'recipe': recipe
    }
    if request.method == 'GET':
        form = RecipeForm(instance=recipe)
        context['form'] = form
        return render(request, 'app/update.html', context)
    elif request.method == 'POST' and request.FILES != None:
        form = RecipeForm(request.POST, request.FILES, instance=recipe)
        if form.is_valid():
            form.save()
            return redirect('detail', recipe.id)
        else:
            form = RecipeForm(instance=recipe)
            context['form'] = form
            return render(request, 'app/update.html', context)
    return render(request, 'app/update.html', context)

A short and simple route rendering a replica of the create view except we're saving the form generically.

app/update.html

{% extends 'base.html' %}
{% block content %}
{% load crispy_forms_tags %}

<br class="mt-0 mb-4">
<div class="container">
    <h4>Update Recipe</h4>
    <p><i>Note: The Ingredients and Directions fields are Markdown Supported. Learn more about markdown <a href="https://www.markdownguide.org/cheat-sheet/" target="_blank" style="text-decoration: none;">here</a>.</i></p>
    <br>
        <form method="post" enctype="multipart/form-data">
            {% csrf_token %}
            <div class="row">
                <div class="col-6">
                    <div class="col">
                        {{ form.name|as_crispy_field }}
                        {{ form.image|as_crispy_field }}
                    </div>
                </div>
                <div class="col-6">
                    {{ form.description|as_crispy_field }}
                </div>
            </div>
            <br>
            <div class="row justify-content-center">
                <div class="col-2">
                    {{ form.prep|as_crispy_field }}
                </div>
                <div class="col-2">
                    {{ form.cook|as_crispy_field }}
                </div>
                <div class="col-2">
                    {{ form.servings|as_crispy_field }}
                </div>
            </div>
            <br>
            <div class="row">
                <div class="col-4">
                    {{ form.ingredients|as_crispy_field }}
                </div>
                <div class="col-4">
                    {{ form.directions|as_crispy_field }}
                </div>
                <div class="col-4">
                    {{ form.notes|as_crispy_field }}
                </div>
            </div>
            <div class="mt-4 mb-4 d-flex justify-content-center">
                <button type="submit" class="btn btn-success">Save Recipe</button>
            </div>
        </form>
    {{ form.media }}
</div>

{% endblock %}   

Go ahead and give it a try, your form should be showing you the data of the object and saving it properly to the database.

Delete

As much as it hurts deleting our database objects, sometimes it's what the user or we, want to do.

app/views.py

@login_required
def delete(request, id):
    recipe = get_object_or_404(Recipe, id=id)
    if not request.user == recipe.author:
        return redirect('detail', recipe.id)
    else:
        name = recipe.name
        recipe.delete()
        context = {
            'name': name
        }
        return render(request, 'app/delete.html', context)

app/delete.html

{% extends 'base.html' %}
{% block content %}
{% load crispy_forms_tags %}

<br class="mt-0 mb-4">
<div class="container">
    <h4>You have successfully deleted the {{name|capfirst}} Recipe</h4>
    <br><br>
    <div class="row">
        <div class="col"><a href="{% url 'home' %}"><button class="btn btn-primary">Back Home</button></a></div>
        <div class="col"><a href="{% url 'create' %}"><button class="btn btn-success">New Recipe</button></a></div>
    </div>
</div>
{% endblock %}

Part 3 πŸš—

We are now ready for our live deployment to Heroku. At this point, please head over to Github, create and push your code to a new repository so we can host it on Heroku. Also, if you don't already have a Heroku account, head to Heroku Home to create one.

Additionally, Heroku needs gunicorn to run so we'll install it with pip.

pip install gunicorn
pip freeze > requirements.txt

Next, we need a

Procfile
so Heroku knows to run our app with gunicorn.

web: gunicorn foodanic.wsgi --log-file -

Make sure you are logged into Heroku in your terminal with:

heroku login

Create a new Heroku app with:

heroku create
# or
heroku create [app-name]

After you git and commit, run

git push heroku HEAD:master

When you get a success message saying your app (with link) was deployed to Heroku, we have to ensure that our app accepts that reference.

foodanic/settings.py

ALLOWED_HOSTS = ['[your-app].herokuapp.com']

Be sure to replace

['your-app']
with the corresponding app name for your Heroku app.

Then redo the commit and...

git push heroku HEAD:master

The End

If you have reached the end of this tutorial, you are awesome! If you encountered any bugs/errors throughout the tutorial, please don't be shy to post them in the comments so I can fix them quickly before anyone else experiences them. Programming is a collaborative effort after all 😁

Project Links:

Resources:

  • Detail Page Codepen: Link
  • User Auth Page Codepen: Link
  • Home Page Bootstrap Template: Link
  • Django MarkdownX: Link
  • Django Crispy Forms: Link

P.S. If there are certain projects/topics you'd like me to dive into, drop them in the comments below and I will do my best to research it. Thanks for reading πŸ™‚

Also published on: https://medium.com/analytics-vidhya/how-to-build-a-django-web-app-from-scratch-tutorial-20034f0a3043

Tags

Join Hacker Noon

Create your free account to unlock your custom reading experience.