No Time Dad

A blog about web development written by a busy dad

Django Small Project - Workout Tracker (Pt 2)

Table of Contents for Project

Intro

In the first part of this series I focused on scaffolding a new Django project and implementing a basic authentication system that included user registration, login, and logout views. You’ll probably want to go through the first part of the project before this one, but you can certainly just read through this as a reference. For brevity, I’ll probably be leaving out some of the code from the first part.

In this part of the series I am going to focus on adding some models, as well as some forms and views for those models. I am going to primarily focus on using Django’s built-in views and forms as much as possible. Avoiding custom logic as much as possible because it simply takes more time and Django usually has a built-in feature for what you want to build anyways.

As a reminder, I am using sqlite for this project because it is small and doesn’t require much (if any) configuration. I will probably use sqlite in production too since I don’t really anticipate much traffic (if any). I’ll also just focus on getting the views working and worry about styling the templates later.

Goals For This Session

  1. Design the models
  2. Exercise create view
  3. Exercise list view
  4. Exercise detail view
  5. Exercise update view
  6. Exercise delete view

So, sort of a lot to do here. But maybe since we are leveraging a lot of built-in features of Django it won’t be so bad. Again, I am trying to keep my working time to around an hour, so we’ll see how it goes. If I have to drop some tasks I will.

Design The Models

We’re going to start with just one model for now and see how things go. Our primary “feature” for the Workout Tracker app is workouts. We can assume that a “workout” is a series of exercises on a given day. With this, we also assume that people are only performing one workout per day.

Something to consider here is to have two tables; Exercise and Workout. The Exercise table would have a ForeignKey relationship to the Workout table. The only issue I have with this is that a workout would need to exist first before you can create an exercise. Alternatively, an exercise could exist but it would need to be mapped to an existing workout at some point by the user. Basically, either way you probably end up having two forms that the user will need to fill out.

Having an exercise model with a relationship to a workout model is probably the right way to design this, but I would like this app to be ultra simple for right now so I am going to only create an exercise model and assume that I am doing one workout per day.

Ugh, models are hard.

  1. Open track app’s models.py and add the code shown below. Note that we are creating a relationship between an exercise and the user, the create_by field.
# workout_project/tracker_app/models.py
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.contrib.auth.models import User

from django.db import models

class Exercise(models.Model):
  name = models.CharField(max_length=100)
  sets = models.PositiveIntegerField()
  reps = models.PositiveIntegerField()
  weight = models.PositiveIntegerField()
  performed = models.DateTimeField(auto_now_add=True)
  created = models.DateTimeField(auto_now_add=True)
  updated = models.DateTimeField(auto_now_add=True)
  created_by = models.ForeignKey(User, on_delete=models.CASCADE)

  def __str__(self):
        return self.name
  1. We have added a new model so we need to create and apply the migration.
python manage.py makemigrations
python manage.py migrate

Exercise Create View

For the workout create view we are going to use Django’s CreateView. We’ll need to create an html template for this view, which we can re-use for the edit workout view (super handy).

Another thing we’ll need to do here is think about the ForeignKey relationship we have to the User model. We basically want to create this relationship “behind the scenes”. Meaning, if we include the created_by field in our view then the user will have to select their own username in the form which is super awkward and not something you want to ever do. We’ll take care of this by overriding the form_valid method in the CreateView class. We’re also assuming that there is an authenticated user when we do this, so we’ll need to ensure that only logged in users have access to this view.

We’ll inherit Django’s CreateView class which gives us access to the request object via the self.request property (shown below). The request object will also contain the authenticated user, which we can map to the create_by field on our Exercise model.

  1. Open tracker app’s views.py and add the following code
# workout_project/tracker_app/views.py
from django.views.generic.edit import CreateView
from django.urls import reverse_lazy
from tracker_app.models import Exercise

class ExerciseCreateView(CreateView):
  model = Exercise
  fields = ["name", "sets", "reps", "weight"]
  success_url = reverse_lazy("tracker_app:home")

  def form_valid(self, form):
    form.instance.created_by = self.request.user
    return super().form_valid(form)
  1. Create the html template file (Django knows to look for exercise_form.html by default for the CreateView and UpdateView)
touch tracker_app/templates/tracker_app/exercise_form.html
  1. Update exercise_form.html to contain the following
<h1>Create Exercise</h1>
<form method="POST">
  {% csrf_token %} {{form.as_p}}
  <input type="submit" value="Save" />
</form>
  1. Map our new ExerciseCreateView to a url in urls.py, and utilize Django’s login_required decorator to ensure only logged in users can access this view
# workout_project/tracker_app/urls.py
from django.urls import path
from django.contrib.auth.views import LoginView, LogoutView
from django.contrib.auth.decorators import login_required

import tracker_app.views as tracker_views

app_name = "tracker_app"
urlpatterns = [
  path("", tracker_views.HomeView.as_view(), name="home"),
  path("login/", LoginView.as_view(), name="login"),
  path("logout/", LogoutView.as_view(), name="logout"),
  path("signup/", tracker_views.SignupView.as_view(), name="signup"),
  path(
    "create/",
    login_required(tracker_views.ExerciseCreateView.as_view()),
    name="exercise_create"),
]
  1. At this point we can visit http://127.0.0.1:8000/tracker/create/ to confirm that our view is working. You can also go ahead and create an exercise.

Exercise List View

It might be handy to see a list of the exercises we’ve already created. I like doing this for debugging purposes, but also at some point the user is going to want to actually see the exercises they’ve added.

We are going to utilize Django’s built-in ListView for this. In this implementation we are just going to list all exercises. We want to eventually group these by day to consitute a “workout”.

  1. Open tracker app’s views.py and lets add a new a view class
from django.views.generic.edit import CreateView
from django.urls import reverse_lazy
from django.views.generic.list import ListView
from tracker_app.models import Exercise

class ExerciseCreateView(CreateView):
  model = Exercise
  fields = ["name", "sets", "reps", "weight"]
  success_url = reverse_lazy("tracker_app:home")

  def form_valid(self, form):
    form.instance.created_by = self.request.user
    return super().form_valid(form)

class ExerciseListView(ListView):
  model = Exercise
  context_object_name = "exercises" # friendly queryset name for the template
  1. Django is going to look for a html file called exercise_list.html (model_name + _list.html), so we’ll go ahead and create it now.
touch tracker_app/templates/tracker_app/exercise_list.html
  1. Add the following html to exercise_list.html
<h1>Exercises</h1>
<ul>
  {% for exercise in exercises %}
  <li>{{exercise}}</li>
  {% empty %}
  <li>No exercises.</li>
  {% endfor %}
</ul>
  1. Now we can map the new view to a path in tracker_app/urls.py, which does not require a login to view.
from django.urls import path
from django.contrib.auth.views import LoginView, LogoutView
from django.contrib.auth.decorators import login_required

import tracker_app.views as tracker_views

app_name = "tracker_app"
urlpatterns = [
  path("", tracker_views.HomeView.as_view(), name="home"),
  path("login/", LoginView.as_view(), name="login"),
  path("logout/", LogoutView.as_view(), name="logout"),
  path("signup/", tracker_views.SignupView.as_view(), name="signup"),
  path(
    "create/",
    login_required(tracker_views.ExerciseCreateView.as_view()),
    name="exercise_create"),
  path(
    "all/",
    tracker_views.ExerciseListView.as_view(),
    name="exercise_list"),
]
  1. You can now visit http://127.0.0.1:8000/tracker/all/ and you should see any exercises you created or “No exercises.”.

Exercise Detail View

The detail view provides a way to view a single exercise at a time. It is also a good place to add links for editing, and deleting.

  1. Update tracker app’s views.py to include a new class for the detail view
# workout_project/tracker_app/views.py
from django.views.generic.edit import CreateView, UpdateView
from django.urls import reverse_lazy
from django.views.generic.list import ListView
from tracker_app.models import Exercise

class ExerciseCreateView(CreateView):
  model = Exercise
  fields = ["name", "sets", "reps", "weight"]
  success_url = reverse_lazy("tracker_app:home")

  def form_valid(self, form):
    form.instance.created_by = self.request.user
    return super().form_valid(form)

class ExerciseListView(ListView):
  model = Exercise
  context_object_name = "exercises" # friendly queryset name for the template

class ExerciseDetailView(DetailView):
  model = Exercise
  context_object_name = "exercise"
  1. Create an html template file called exercise_detail.html (which is what Django looks for by default)
<h1>Exercise Detail</h1>
<ul>
  <li>Name: {{exercise.name}}</li>
  <li>Created: {{exercise.created|date}}</li>
  <li>Sets: {{exercise.sets}}</li>
  <li>Reps: {{ exercise.reps}}</li>
  <li>Weight: {{exercise.weight}}</li>
</ul>
  1. Map the new ExerciseDetailView to a path in tracker app’s urls.py
from django.urls import path
from django.contrib.auth.views import LoginView, LogoutView
from django.contrib.auth.decorators import login_required

import tracker_app.views as tracker_views

app_name = "tracker_app"
urlpatterns = [
  path("", tracker_views.HomeView.as_view(), name="home"),
  path("login/", LoginView.as_view(), name="login"),
  path("logout/", LogoutView.as_view(), name="logout"),
  path("signup/", tracker_views.SignupView.as_view(), name="signup"),
  path(
    "create/",
    login_required(tracker_views.ExerciseCreateView.as_view()),
    name="exercise_create"),
  path(
    "all/",
    tracker_views.ExerciseListView.as_view(),
    name="exercise_list"),
  path(
    "<int:pk>/",
    tracker_views.ExerciseDetailView.as_view(),
    name="exercise_detail"
  ),
]
  1. Let’s go ahead and update exercise_list.html with a link to the detail view so it is easier to get to
<h1>Exercises</h1>
<ul>
  {% for exercise in exercises %}
  <li>
    {{exercise}}
    <a href="{% url 'tracker_app:exercise_detail'  exercise.id %}">View</a>
  </li>
  {% empty %}
  <li>No exercises.</li>
  {% endfor %}
</ul>
  1. If you have any exercises saved, you should now be able to view them all at http://127.0.0.1:8000/tracker/all/ and click on the link we just created to view a particular one.

  2. Since we have a way to list the exercises, it would be helpful to users if we provided a link to create an exercise. We’ll go ahead and add a link to the list exercise template so that authenticated users can create new exercises. Update exercise_list.html to look like the below.

<h1>Exercises</h1>
{% if user.is_authenticated %}
<a href="{% url 'tracker_app:exercise_create' %}">Create Exercise</a>
{% endif %}
<ul>
  {% for exercise in exercises %}
  <li>
    {{exercise}}
    <a href="{% url 'tracker_app:exercise_detail'  exercise.id %}">View</a>
  </li>
  {% empty %}
  <li>No exercises.</li>
  {% endfor %}
</ul>

Exercise Update View

Updating an exercise will be pretty easy using Django’s UpdateView. As mentioned previously, Django will let us just re-use the exercise_form.html we made for creating exercises previously.

A couple things to note here about updating objects. Generally speaking, we usually only want the user who created the object to be able to update it. The UpdateView class provided by Django has a method called test_func which allows us to write a custom check on a given scenario. Our scenario here is that the authenticated user must be the same user as create_by on the Exercise model. If not, the user will be redirected to 403 page by Django automatically. The update view is another view that we will want to require a login for.

Additionally, after a user updates an object we’ll probably want to redirect back to the detail view for that object so they can see their changes. I think this is a good user experience, but you are welcome to redirect them somewhere else if you like. We’ll override the get_success_url method so we can pass the primary key in the url for the exercise record we are updating. We can do this because the UpdateView we inherit provides us with the exercise object we are working with via the self.object class property.

  1. Open tracker app’s views.py and add the update view class as shown below
# workout_project/tracker_app/views.py
from django.views.generic.edit import CreateView, UpdateView
from django.urls import reverse_lazy
from django.views.generic.list import ListView
from tracker_app.models import Exercise

class ExerciseCreateView(CreateView):
  model = Exercise
  fields = ["name", "sets", "reps", "weight"]
  success_url = reverse_lazy("tracker_app:home")

  def form_valid(self, form):
    form.instance.created_by = self.request.user
    return super().form_valid(form)

class ExerciseListView(ListView):
  model = Exercise
  context_object_name = "exercises" # friendly queryset name for the template

class ExerciseDetailView(DetailView):
  model = Exercise
  context_object_name = "exercise"

class ExerciseUpdateView(UpdateView):
  model = Exercise
  fields = ["name", "sets", "reps", "weight"]

  def get_success_url(self):
      return reverse_lazy("tracker_app:exercise_detail", kwargs={'pk': self.object.pk})

  def test_func(self):
    exercise = self.get_object()
    return self.request.user == exercise.created_by
  1. Since we’ll be re-using an existing html template, we can jump right to updating tracker app’s urls.py for this new view, making sure to include the login_required decorator for the update path.
from django.urls import path
from django.contrib.auth.views import LoginView, LogoutView
from django.contrib.auth.decorators import login_required

import tracker_app.views as tracker_views

app_name = "tracker_app"
urlpatterns = [
  path("", tracker_views.HomeView.as_view(), name="home"),
  path("login/", LoginView.as_view(), name="login"),
  path("logout/", LogoutView.as_view(), name="logout"),
  path("signup/", tracker_views.SignupView.as_view(), name="signup"),
  path(
    "create/",
    login_required(tracker_views.ExerciseCreateView.as_view()),
    name="exercise_create"),
  path(
    "all/",
    tracker_views.ExerciseListView.as_view(),
    name="exercise_list"),
  path(
    "<int:pk>/",
    tracker_views.ExerciseDetailView.as_view(),
    name="exercise_detail"
  ),
  path(
    "<int:pk>/update/",
    login_required(tracker_views.ExerciseUpdateView.as_view()),
    name="exercise_update"),
]
  1. We can now add a “protected” link to our workout detail view template so we can quickly access the update view. The link will be hidden from any user who is not the created_by user, and any user who tries to access the view directly via the url will be redirected to a 403 page because of the test_func method we added to the ExerciseUpdateView class.
<h1>Exercise Detail</h1>
<ul>
  <li>Name: {{exercise.name}}</li>
  <li>Created: {{exercise.created|date}}</li>
  <li>Sets: {{exercise.sets}}</li>
  <li>Reps: {{ exercise.reps}}</li>
  <li>Weight: {{exercise.weight}}</li>
</ul>

{% if user == exercise.created_by %}
<a href="{% url 'tracker_app:exercise_update' exercise.id %}">Edit</a>
{% endif %}
  1. You should now be able to log in, create an exercise, view all exercises, view an exercise, and edit an exercise (if you are the owner of it)

Exercise Delete View

The last thing we need to do is give users the ability to delete an exercise. Again, they should only be able to delete the exercise object if they are logged in and are the owner of it.

Django provides a view for this called DeleteView. This view is basically just a confirmation page with a simple form. We’ll just need to add the test_func for the ownership test, and we’ll need to tell Django were to redirect to after a user confirms that they want to delete the exercise via the success_url property. In the UpdateView we redirected back to the detail view for the given exercise, but we are deleting here so the exercise will not exist anymore. I think the best user experience for this would be redirect back to our exercise list view.

  1. Let’s create the new class in tracker app’s views.py
# workout_project/tracker_app/views.py
from django.views.generic.edit import CreateView, UpdateView
from django.urls import reverse_lazy
from django.views.generic.list import ListView
from tracker_app.models import Exercise

class ExerciseCreateView(CreateView):
  model = Exercise
  fields = ["name", "sets", "reps", "weight"]
  success_url = reverse_lazy("tracker_app:home")

  def form_valid(self, form):
    form.instance.created_by = self.request.user
    return super().form_valid(form)

class ExerciseListView(ListView):
  model = Exercise
  context_object_name = "exercises" # friendly queryset name for the template

class ExerciseDetailView(DetailView):
  model = Exercise
  context_object_name = "exercise"

class ExerciseUpdateView(UpdateView):
  model = Exercise
  fields = ["name", "sets", "reps", "weight"]

  def get_success_url(self):
      return reverse_lazy("tracker_app:exercise_detail", kwargs={'pk': self.object.pk})

  def test_func(self):
    exercise = self.get_object()
    return self.request.user == exercise.created_by

class ExerciseDeleteView(DeleteView):
  model = Exercise
  success_url = reverse_lazy("tracker_app:exercise_list")

  def test_func(self):
    exercise = self.get_object()
    return self.request.user == exercise.created_by
  1. Django is going to be looking for a html template file called exercise_confirm_delete.html so we’ll go ahead and create that file
touch tracker_app/templates/tracker_app/exercise_confirm_delete.html
  1. Add the following html to the exercise_confirm_delete.html file you just created
<h1>Confirm Delete</h1>
<form method="post">
  {% csrf_token %}
  <p>Are you sure you want to delete "{{ object }}"?</p>
  <input type="submit" value="Confirm" />
</form>
  1. Map the new view to a path in tracker app’s urls.py, again wrapping this view with the login_required decorator.
from django.urls import path
from django.contrib.auth.views import LoginView, LogoutView
from django.contrib.auth.decorators import login_required

import tracker_app.views as tracker_views

app_name = "tracker_app"
urlpatterns = [
  path("", tracker_views.HomeView.as_view(), name="home"),
  path("login/", LoginView.as_view(), name="login"),
  path("logout/", LogoutView.as_view(), name="logout"),
  path("signup/", tracker_views.SignupView.as_view(), name="signup"),
  path(
    "create/",
    login_required(tracker_views.ExerciseCreateView.as_view()),
    name="exercise_create"),
  path(
    "all/",
    tracker_views.ExerciseListView.as_view(),
    name="exercise_list"),
  path(
    "<int:pk>/",
    tracker_views.ExerciseDetailView.as_view(),
    name="exercise_detail"
  ),
  path(
    "<int:pk>/update/",
    login_required(tracker_views.ExerciseUpdateView.as_view()),
    name="exercise_update"),
  path(
    "<int:pk>/delete/",
    login_required(tracker_views.ExerciseDeleteView.as_view()),
    name="exercise_delete"),
]
  1. We’ll add another link to our exercise detail template so users can easily access the delete page. Update exercise_detail.html to look like the below.
<h1>Exercise Detail</h1>
<ul>
  <li>Name: {{exercise.name}}</li>
  <li>Created: {{exercise.created|date}}</li>
  <li>Sets: {{exercise.sets}}</li>
  <li>Reps: {{ exercise.reps}}</li>
  <li>Weight: {{exercise.weight}}</li>
</ul>

{% if user == exercise.created_by %}
<a href="{% url 'tracker_app:exercise_update' exercise.id %}">Edit</a>
<a href="{% url 'tracker_app:exercise_delete' exercise.id %}">Delete</a>
{% endif %}
  1. Now if you view the detail page for an exercise you should be able to click on the Delete link and be taken to the delete page. You can then delete an exercise and confirm that you are taken back to the exercise list page.

Wrapping Up

Phew. That is pretty much it for today. Users can now signup, login, logout, and create, view, update, and delete an exercise. Not bad for a couple hours worth of work. You’ll notice that I did not write a single get, post, delete, put, or any other http handler method. Django provided that all for us. We did have to override and extend a few methods, but it was really pretty minimal. At this point our views are all still in a single file and we haven’t written a single form class yet. That will change, but it is still pretty impressive how far you can get without doing much work in Django.

Obviously the app needs more functionality, not to mention some better styling. Part 3 of this tutorial will take care of some of that. Stay tuned.