visit
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: Demo:django-admin startproject [projectname]
├─ 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.pyvirtualenv env
Note: the [env] can be anything you want to name your virtual environment
To activate the environment:source env/bin/activate
deactivate
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 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)
touch .gitignore
# .gitignore
env/
python manage.py startapp app
python manage.py startapp users
# 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)
Files to add: Folders to add: (for each respectively)
- urls.py - app/templates/app
- forms.py - users/templates/users
├─ 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
# foodanic/settings.py
...
LOGIN_REDIRECT_URL = '/'
LOGOUT_REDIRECT_URL = '/'
LOGIN_URL = 'login'
# 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)
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
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="//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 %}
python manage.py createsuperuser
# 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)
# 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)
python manage.py migrate
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
# 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 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
# settings.py
INSTALLED_APPS = [
'markdownx',
...
]
pip freeze > requirements.txt
# foodanic/urls.py
urlpatterns = [
path('markdownx/', include('markdownx.urls')),
]
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) ✗
# app/admin.py
from django.contrib import admin
from .models import *
admin.site.register(Recipe)
# 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, .Install it with pip:pip install django-durationwidget
pip install django-crispy-forms
# settings.py
INSTALLED_APPS = [
'durationwidget',
'crispy_forms',
]
TEMPLATES = [
'APP_DIRS': True, # set to True
]
# on the bottom of settings.py
CRISPY_TEMPLATE_PACK = 'bootstrap4'
pip freeze > requirements.txt
# 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)
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 .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="//cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-BmbxuPwQa2lc/FVzBcNJ7UAyJxM6wuqIj61tLrc4wSX0szH/Ev+nYRRuWlolflfl" crossorigin="anonymous">
<link rel="shortcut icon" href="//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="//cdn.jsdelivr.net/npm/[email protected]/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="//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: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)
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="//cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.5.3/js/bootstrap.min.js"></script>
<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.2/css/all.min.css">
{% endblock %}
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 %}
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)
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="//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 %}
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 %}
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 -
heroku login
heroku create
# or
heroku create [app-name]
git push heroku HEAD:master
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
P.S. If there are certain projects/topics you'd like me to dive into, drop them in the comments below and I will do my best to research it. Thanks for reading 🙂
Also published on: