DEV Community

vladyslav nykoliuk
vladyslav nykoliuk

Posted on • Updated on

How to build a Django web app from scratch (tutorial)[2024 revised]

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:
Home Page

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

source env/bin/activate
Enter fullscreen mode Exit fullscreen mode

To deactivate the environment:

deactivate 
Enter fullscreen mode Exit fullscreen mode

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')],
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

To install the dependency, run:

pip install whitenoise
Enter fullscreen mode Exit fullscreen mode

Add whitenoise to your MIDDLEWARE:

# settings.py
MIDDLEWARE = [
   ...
   'whitenoise.middleware.WhiteNoiseMiddleware',
]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

# .gitignore
env/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Add the app to settings.py:

# settings.py
INSTALLED_APPS = [
    'app',
    'users',
     ...
]
Enter fullscreen mode Exit fullscreen mode

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)

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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)

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 %}
Enter fullscreen mode Exit fullscreen mode

Login View

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 %}
Enter fullscreen mode Exit fullscreen mode

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 %}

Enter fullscreen mode Exit fullscreen mode

Sign Up View

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 %}
Enter fullscreen mode Exit fullscreen mode

Change Password View

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 %}
Enter fullscreen mode Exit fullscreen mode

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 %}
Enter fullscreen mode Exit fullscreen mode

Forgot Password View

users/password_reset_subject.txt

Foodanic Password Reset
Enter fullscreen mode Exit fullscreen mode

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 %}

Enter fullscreen mode Exit fullscreen mode

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 %}
Enter fullscreen mode Exit fullscreen mode

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 %}
Enter fullscreen mode Exit fullscreen mode

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 %}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

users/views.py

from django.shortcuts import render, redirect
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.forms import UserCreationForm, PasswordChangeForm, AuthenticationForm

def signup(request):
    context = {}
    if request.method == 'POST':
        form = UserCreationForm(request.POST)
        if form.is_valid():
            form.save()
            username = form.cleaned_data.get('username')
            password = form.cleaned_data.get('password1')
            user = request.user
            if user is not None:
                return redirect('login')
            else:
                print('user is none')
        else:
            form = UserCreationForm()
            context['form'] = form
            return render(request, 'users/signup.html', context)
        # context['form'] = form
    if request.method == 'GET':
        form = UserCreationForm()
        context['form'] = form
        return render(request, 'users/signup.html', context)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Run server with:

python manage.py runserver [OPTIONAL: PORT]
Enter fullscreen mode Exit fullscreen mode

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:

# app/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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Add it to settings.py:

# settings.py

INSTALLED_APPS = [
   'markdownx',
   ...
]
Enter fullscreen mode Exit fullscreen mode

Add it to requirements with:

pip freeze > requirements.txt
Enter fullscreen mode Exit fullscreen mode

Add it to main urls.py:

# foodanic/urls.py

urlpatterns = [
    path('markdownx/', include('markdownx.urls')),
]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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) βœ— 
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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',)

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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

pip freeze > requirements.txt
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 %}   
Enter fullscreen mode Exit fullscreen mode

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:

Screen Shot 2021-03-01 at 9.11.49 PM

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)
Enter fullscreen mode Exit fullscreen mode

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 %} 
Enter fullscreen mode Exit fullscreen mode

The new template should look like this:

Detail Page View

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 %}
Enter fullscreen mode Exit fullscreen mode

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

Home Page View

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)
Enter fullscreen mode Exit fullscreen mode

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 %}   
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 %}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Next we need a Procfile so Heroku knows to run our app with gunicorn.

web: gunicorn foodanic.wsgi --log-file -
Enter fullscreen mode Exit fullscreen mode

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

heroku login
Enter fullscreen mode Exit fullscreen mode

Create a new Heroku app with:

heroku create
# or
heroku create [app-name]
Enter fullscreen mode Exit fullscreen mode

After you git and commit, run

git push heroku HEAD:master
Enter fullscreen mode Exit fullscreen mode

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']
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

Github Repo: Link
Live: Link

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 πŸ™‚

Top comments (7)

Collapse
 
vladislava profile image
Vladislava05

It is awesome! I've been working on my pet project! I would be more than happy if you supported us! Hey guys! I'm only 16 but I've already created my own TaskMaster. There is only MVP now, but we are working hard! I would be happy if you support us:)
github.com/Vladislava05/TaskMaster
In readme you can find the link to our web app

Collapse
 
codebyline profile image
Yumei Leventhal

This is awesome! I've been collecting snippets on my own with lots of overlapping sections. Great to have everything in one place. Thanks!

Collapse
 
vladyslavnua profile image
vladyslav nykoliuk

I'm so happy to hear that! That's the intention I had while drafting it πŸ™‚

Collapse
 
codebyline profile image
Yumei Leventhal

I got the app set up and it looks great on my laptop. So I followed your instructions to deploy it on Heroku (which I never was able to figure out.) It took some effort --'favicon.ico' kept crashing the app and I ended up removing the reference to it. The app finally runs on Heroku. However, the images wouldn't show up. I duplicated the images in various possible folders just in case they were in the wrong place--but that didn't make any difference. I don't mean to have you spend time on this, but I am curious whether this is related to how Heroku operates. And, since the Recipe model is set up to receive uploaded images, so short of modifying the model, external images (from the web) can't be easily linked, can it? : frozen-fjord-60733.herokuapp.com/

Thread Thread
 
vladyslavnua profile image
vladyslav nykoliuk • Edited

Hi! So sorry for the delay in reply. I just checked your website it looks like you fixed it and it looks great! For anyone seeing this in future reference - a few possible scenarios:

  1. A static/media root directory error

Solution: Check how you're setting the STATIC_ROOT and MEDIA_ROOT

  1. Whitenoise wasn't properly installed, or saved to requirements

Solution: pip install whitenoise && pip freeze > requirements.txt

To answer your question about external images, the only way I know of referencing an external image by url would be to add another optional field and request that url for the image. A quick example can be seen over on Stack Overflow.

P.S. There seems to be a Markdown glitch for my comment where it doesn't change the second answer to number 2. Goes to show edge cases always exist :) πŸ˜‚

Thread Thread
 
codebyline profile image
Yumei Leventhal • Edited

Thanks for looking into it! I finally figured it out this morning. Just like you said, the issue was related to how the static and media directories were set up. Whitenoise was set up exactly as directed (and works seamlessly). What I ended up doing was deleting all the images from all the static and media folders, modified the path in settings. The app works, though I don't know why. I posted on Stackoverflow hoping to get some explanation, as I've never understood completely how to set up static files aside from copy and paste. So this is my chance to get it sorted it out.

Thread Thread
 
vladyslavnua profile image
vladyslav nykoliuk

ahh gotcha. Yup, I remember I actually tried to answer it on there too :)