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
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)
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)
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:
# 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)
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 © 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 π
Top comments (7)
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
This is awesome! I've been collecting snippets on my own with lots of overlapping sections. Great to have everything in one place. Thanks!
I'm so happy to hear that! That's the intention I had while drafting it π
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/
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:
Solution: Check how you're setting the STATIC_ROOT and MEDIA_ROOT
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 :) π
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.
ahh gotcha. Yup, I remember I actually tried to answer it on there too :)