Skip to contentSkip to navigationSkip to topbar
Rate this page:
On this page

Send Appointment Reminders with Python and Django


(information)

Info

Ahoy! We now recommend you build your appointment reminders with Twilio's built in Message Scheduling functionality. Head on over to the Message Scheduling documentation to learn more about scheduling messages!

Ready to implement SMS appointment reminders in your Django web application? We'll use the Twilio Python Helper Library and Twilio SMS API to push out reminders to our customers when appointments are near. Here's how it works at a high level:

  1. An administrator (our user) creates an appointment for a future date and time, and stores a customer's phone number in the database for that appointment
  2. When that appointment is saved a background task is scheduled to send a reminder to that customer before their appointment starts
  3. At a configured time in advance of the appointment, the background task sends an SMS reminder to the customer to remind them of their appointment

Check out how Yelp uses SMS to confirm restaurant reservations for diners.(link takes you to an external page)


Appointment Reminder Building Blocks

appointment-reminder-building-blocks page anchor

Here are the technologies we'll use:


How To Read This Tutorial

how-to-read-this-tutorial page anchor

To implement appointment reminders, we will be working through a series of user stories(link takes you to an external page) that describe how to fully implement appointment reminders in a web application.

We'll walk through the code required to satisfy each story, and explore what we needed to add at each step.

All this can be done with the help of Twilio in under half an hour.


Meet our Django Appointment Reminder Stack

meet-our-django-appointment-reminder-stack page anchor

We're building this app for Django 2.1 on Python 3.7. We're big fans of Two Scoops of Django(link takes you to an external page) and we will use many best practices outlined there.

In addition to Dramatiq, we will use a few other Python libraries to make our task easier:

We will also use PostgreSQL(link takes you to an external page) for our database and Redis(link takes you to an external page) as our Dramatiq message broker(link takes you to an external page).

Project dependencies

project-dependencies page anchor
requirements.txt

_63
appdirs==1.4.3
_63
appnope==0.1.0
_63
arrow==0.15.7
_63
asgiref==3.2.5
_63
attrs==19.3.0
_63
backcall==0.1.0
_63
black==19.10b0
_63
certifi==2019.11.28
_63
cfgv==3.1.0
_63
chardet==3.0.4
_63
click==7.1.1
_63
decorator==4.4.2
_63
distlib==0.3.0
_63
Django==3.0.4
_63
django-dramatiq==0.9.1
_63
django-bootstrap3==14.0.0
_63
django-environ==0.4.5
_63
django-forms-bootstrap==3.1.0
_63
django-timezone-field==4.0
_63
dramatiq[rabbitmq,watch]==1.9.0
_63
entrypoints==0.3
_63
filelock==3.0.12
_63
flake8==3.7.9
_63
identify==1.4.11
_63
idna==2.9
_63
importlib-metadata==1.5.0
_63
ipdb==0.13.2
_63
ipython==7.13.0
_63
ipython-genutils==0.2.0
_63
isort==4.3.21
_63
jedi==0.16.0
_63
mccabe==0.6.1
_63
mock==4.0.2
_63
model-mommy==2.0.0
_63
nodeenv==1.3.5
_63
parso==0.6.2
_63
pathspec==0.7.0
_63
pexpect==4.8.0
_63
pickleshare==0.7.5
_63
pre-commit==2.2.0
_63
prompt-toolkit==3.0.4
_63
ptyprocess==0.6.0
_63
pycodestyle==2.5.0
_63
pyflakes==2.1.1
_63
Pygments==2.6.1
_63
PyJWT==1.7.1
_63
pytz==2019.3
_63
PyYAML==5.3
_63
redis==3.5.3
_63
regex==2020.2.20
_63
requests==2.23.0
_63
selenium==3.141.0
_63
six==1.14.0
_63
sqlparse==0.3.1
_63
toml==0.10.0
_63
traitlets==4.3.3
_63
twilio==6.36.0
_63
typed-ast==1.4.1
_63
urllib3==1.25.8
_63
virtualenv==20.0.10
_63
wcwidth==0.1.8
_63
whitenoise==5.1.0
_63
zipp==3.1.0

Now that we have all our depenencies defined, we can get started with our first user story: creating a new appointment.


As a user, I want to create an appointment with a name, guest phone number, and a time in the future.

To build an automated appointment reminder app, we probably should start with an appointment. This story requires that we create a model object and a bit of the user interface to create and save a new Appointment in our system.

At a high level, here's what we will need to add:

  • An Appointment model to store information we need to send the reminder
  • A view to render our form and accept POST data from it
  • An HTML form to enter details about the appointment

Alright, so we know what we need to create a new appoinment. Now let's start by looking at the model, where we decide what information we want to store with the appointment.


We only need to store four pieces of data about each appointment to send a reminder:

  • The customer's name
  • Their phone number
  • The date and time of their appointment
  • The time zone of the appointment

We also included two additional fields: task_id and created. The task_id field will help us keep track of the corresponding reminder task for this appointment. The created field is just a timestamp populated when an appointment is created.

Finally, we defined a __str__ method to tell Django how to represent instances of our model as text. This method uses the primary key and the customer's name to create a readable representation of an appointment.

Appointment model fields

appointment-model-fields page anchor

reminders/models.py


_80
from __future__ import unicode_literals
_80
_80
import redis
_80
_80
from django.core.exceptions import ValidationError
_80
from django.conf import settings
_80
from django.db import models
_80
from django.urls import reverse
_80
from six import python_2_unicode_compatible
_80
from timezone_field import TimeZoneField
_80
_80
import arrow
_80
_80
_80
@python_2_unicode_compatible
_80
class Appointment(models.Model):
_80
name = models.CharField(max_length=150)
_80
phone_number = models.CharField(max_length=15)
_80
time = models.DateTimeField()
_80
time_zone = TimeZoneField(default='UTC')
_80
_80
# Additional fields not visible to users
_80
task_id = models.CharField(max_length=50, blank=True, editable=False)
_80
created = models.DateTimeField(auto_now_add=True)
_80
_80
def __str__(self):
_80
return 'Appointment #{0} - {1}'.format(self.pk, self.name)
_80
_80
def get_absolute_url(self):
_80
return reverse('reminders:view_appointment', args=[str(self.id)])
_80
_80
def clean(self):
_80
"""Checks that appointments are not scheduled in the past"""
_80
_80
appointment_time = arrow.get(self.time, self.time_zone.zone)
_80
_80
if appointment_time < arrow.utcnow():
_80
raise ValidationError(
_80
'You cannot schedule an appointment for the past. '
_80
'Please check your time and time_zone')
_80
_80
def schedule_reminder(self):
_80
"""Schedule a Dramatiq task to send a reminder for this appointment"""
_80
_80
# Calculate the correct time to send this reminder
_80
appointment_time = arrow.get(self.time, self.time_zone.zone)
_80
reminder_time = appointment_time.shift(minutes=-30)
_80
now = arrow.now(self.time_zone.zone)
_80
milli_to_wait = int(
_80
(reminder_time - now).total_seconds()) * 1000
_80
_80
# Schedule the Dramatiq task
_80
from .tasks import send_sms_reminder
_80
result = send_sms_reminder.send_with_options(
_80
args=(self.pk,),
_80
delay=milli_to_wait)
_80
_80
return result.options['redis_message_id']
_80
_80
def save(self, *args, **kwargs):
_80
"""Custom save method which also schedules a reminder"""
_80
_80
# Check if we have scheduled a reminder for this appointment before
_80
if self.task_id:
_80
# Revoke that task in case its time has changed
_80
self.cancel_task()
_80
_80
# Save our appointment, which populates self.pk,
_80
# which is used in schedule_reminder
_80
super(Appointment, self).save(*args, **kwargs)
_80
_80
# Schedule a new reminder task for this appointment
_80
self.task_id = self.schedule_reminder()
_80
_80
# Save our appointment again, with the new task_id
_80
super(Appointment, self).save(*args, **kwargs)
_80
_80
def cancel_task(self):
_80
redis_client = redis.Redis(host=settings.REDIS_LOCAL, port=6379, db=0)
_80
redis_client.hdel("dramatiq:default.DQ.msgs", self.task_id)

Our appointment model is now setup, the next step is writting a view for it.


Django lets developers write views as functions(link takes you to an external page) or classes(link takes you to an external page).

Class-based views are great when your views need to support simple, CRUD-like(link takes you to an external page) features - perfect for our appointments project.

To make a view for creating new Appointment objects, we'll use Django's generic CreateView(link takes you to an external page) class.

All we need to specify is the model it should use and what fields it should include. We don't even need to declare a form - Django will use a ModelForm(link takes you to an external page) for us behind the scenes.

Success messages

Our view is ready to go with just those first three lines of code, but we'll make it a little better by adding the SuccessMessageMixin(link takes you to an external page).

This mixin tells our view to pass the success_message property of our class to the Django messages framework(link takes you to an external page) after a successful creation. We will display those messages to the user in our templates.

reminders/views.py


_44
from django.contrib.messages.views import SuccessMessageMixin
_44
from django.urls import reverse_lazy
_44
from django.views.generic import DetailView
_44
from django.views.generic.edit import CreateView
_44
from django.views.generic.edit import DeleteView
_44
from django.views.generic.edit import UpdateView
_44
from django.views.generic.list import ListView
_44
_44
from .models import Appointment
_44
_44
_44
class AppointmentListView(ListView):
_44
"""Shows users a list of appointments"""
_44
_44
model = Appointment
_44
_44
_44
class AppointmentDetailView(DetailView):
_44
"""Shows users a single appointment"""
_44
_44
model = Appointment
_44
_44
_44
class AppointmentCreateView(SuccessMessageMixin, CreateView):
_44
"""Powers a form to create a new appointment"""
_44
_44
model = Appointment
_44
fields = ['name', 'phone_number', 'time', 'time_zone']
_44
success_message = 'Appointment successfully created.'
_44
_44
_44
class AppointmentUpdateView(SuccessMessageMixin, UpdateView):
_44
"""Powers a form to edit existing appointments"""
_44
_44
model = Appointment
_44
fields = ['name', 'phone_number', 'time', 'time_zone']
_44
success_message = 'Appointment successfully updated.'
_44
_44
_44
class AppointmentDeleteView(DeleteView):
_44
"""Prompts users to confirm deletion of an appointment"""
_44
_44
model = Appointment
_44
success_url = reverse_lazy('list_appointments')

Now that we have a view to create new appointments, we need to add a new URL to our URL dispatcher so users can get to it.


To satisfy the appointment creation user story, we'll create a new URL at /new and point it to our AppointmentCreateView.

Because we're using a class-based view, we pass our view to our URL with the .as_view() method instead of just using the view's name.

Wire up URL with create Appointment view

wire-up-url-with-create-appointment-view page anchor

reminders/urls.py


_26
from django.conf.urls import re_path
_26
_26
from .views import (
_26
AppointmentCreateView,
_26
AppointmentDeleteView,
_26
AppointmentDetailView,
_26
AppointmentListView,
_26
AppointmentUpdateView,
_26
)
_26
_26
urlpatterns = [
_26
# List and detail views
_26
re_path(r'^$', AppointmentListView.as_view(), name='list_appointments'),
_26
re_path(r'^(?P<pk>[0-9]+)$',
_26
AppointmentDetailView.as_view(),
_26
name='view_appointment'),
_26
_26
# Create, update, delete
_26
re_path(r'^new$', AppointmentCreateView.as_view(), name='new_appointment'),
_26
re_path(r'^(?P<pk>[0-9]+)/edit$',
_26
AppointmentUpdateView.as_view(),
_26
name='edit_appointment'),
_26
re_path(r'^(?P<pk>[0-9]+)/delete$',
_26
AppointmentDeleteView.as_view(),
_26
name='delete_appointment'),
_26
]

With a view and a model in place, the last big piece we need to let our users create new appointments is the HTML form.


Our form template inherits from our base template, which you can check out at templates/base.html.

We're using Bootstrap(link takes you to an external page) for the front end of our app, and we use the django-forms-bootstrap(link takes you to an external page) library to help us render our form with the |as_bootstrap_horizontal template filter.

By naming this file appointment_form.html, our AppointmentCreateView will automatically use this template when rendering its response. If you want to name your template something else, you can specify its name by adding a template_name property on our view class.

templates/reminders/appointment_form.html


_57
{% extends "base.html" %}
_57
_57
{% load bootstrap_tags %}
_57
_57
{% block title %}New reminder{% endblock title %}
_57
_57
{% block content %}
_57
<div class="row">
_57
<div class="col-lg-9">
_57
<div class="page-header">
_57
<h1>
_57
{% if not object.pk %}
_57
New appointment
_57
{% else %}
_57
Edit appointment
_57
{% endif %}
_57
</h1>
_57
</div>
_57
_57
<form class="form-horizontal" method="post">
_57
{% csrf_token %}
_57
{{ form|as_bootstrap_horizontal }}
_57
<div class="form-group">
_57
<div class="col-sm-offset-2 col-sm-10">
_57
<a href="#back" class="btn btn-default">Cancel</a>
_57
<button type="submit" class="btn btn-primary">
_57
{% if not object.pk %}
_57
Create appointment
_57
{% else %}
_57
Update appointment
_57
{% endif %}
_57
</button>
_57
</div>
_57
</div>
_57
</form>
_57
</div>
_57
</div>
_57
{% endblock %}
_57
_57
{% block page_css %}
_57
<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/4.7.14/css/bootstrap-datetimepicker.min.css" />
_57
{% endblock %}
_57
_57
{% block page_js %}
_57
<script src="//cdnjs.cloudflare.com/ajax/libs/moment.js/2.10.3/moment.min.js"></script>
_57
<script src="//cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/4.7.14/js/bootstrap-datetimepicker.min.js"></script>
_57
_57
<script type="text/javascript">
_57
$(function() {
_57
$('#id_time').datetimepicker({
_57
format: 'MM/DD/YYYY HH:mm',
_57
extraFormats: ['YYYY-MM-DD HH:mm:ss'],
_57
sideBySide: true
_57
});
_57
});
_57
</script>
_57
{% endblock %}

We are not leaving this form yet. Instead, let's take a closer look at one of its widgets: the datepicker.


Appointment Form Datepicker

appointment-form-datepicker page anchor

To make it easier for our users to enter the date and time of an appointment, we'll use a JavaScript datepicker widget.

In this case, bootstrap-datetimepicker(link takes you to an external page) is a good fit. We include the necessary CSS and JS files from content delivery networks and then add a little custom JavaScript to initialize the widget on the form input for our time field.

templates/reminders/appointment_form.html


_57
{% extends "base.html" %}
_57
_57
{% load bootstrap_tags %}
_57
_57
{% block title %}New reminder{% endblock title %}
_57
_57
{% block content %}
_57
<div class="row">
_57
<div class="col-lg-9">
_57
<div class="page-header">
_57
<h1>
_57
{% if not object.pk %}
_57
New appointment
_57
{% else %}
_57
Edit appointment
_57
{% endif %}
_57
</h1>
_57
</div>
_57
_57
<form class="form-horizontal" method="post">
_57
{% csrf_token %}
_57
{{ form|as_bootstrap_horizontal }}
_57
<div class="form-group">
_57
<div class="col-sm-offset-2 col-sm-10">
_57
<a href="#back" class="btn btn-default">Cancel</a>
_57
<button type="submit" class="btn btn-primary">
_57
{% if not object.pk %}
_57
Create appointment
_57
{% else %}
_57
Update appointment
_57
{% endif %}
_57
</button>
_57
</div>
_57
</div>
_57
</form>
_57
</div>
_57
</div>
_57
{% endblock %}
_57
_57
{% block page_css %}
_57
<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/4.7.14/css/bootstrap-datetimepicker.min.css" />
_57
{% endblock %}
_57
_57
{% block page_js %}
_57
<script src="//cdnjs.cloudflare.com/ajax/libs/moment.js/2.10.3/moment.min.js"></script>
_57
<script src="//cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/4.7.14/js/bootstrap-datetimepicker.min.js"></script>
_57
_57
<script type="text/javascript">
_57
$(function() {
_57
$('#id_time').datetimepicker({
_57
format: 'MM/DD/YYYY HH:mm',
_57
extraFormats: ['YYYY-MM-DD HH:mm:ss'],
_57
sideBySide: true
_57
});
_57
});
_57
</script>
_57
{% endblock %}

Now let's go back to our Appointment model to see what happens after we successfully post this form.


Add a get_absolute_url() Method

add-a-get_absolute_url-method page anchor

When a user clicks "Submit" on our new appointment form, their input will be received by our AppointmentCreateView and then validated against the fields we specified in our Appointment model.

If everything looks good, Django will save the new appointment to the database. We need to tell our AppointmentCreateView where to send our user next.

We could specify a success_url property on our AppointmentCreateView, but by default Django's CreateView(link takes you to an external page) class will use the newly created object's get_absolute_url method to figure out where to go next.

So we'll define a get_absolute_url method on our Appointment model, which uses Django's reverse(link takes you to an external page) utility function to build a URL for this appointment's detail page. You can see that template at templates/reminders/appointment_detail.html.

And now our users are all set to create new appointments.

reminders/models.py


_80
from __future__ import unicode_literals
_80
_80
import redis
_80
_80
from django.core.exceptions import ValidationError
_80
from django.conf import settings
_80
from django.db import models
_80
from django.urls import reverse
_80
from six import python_2_unicode_compatible
_80
from timezone_field import TimeZoneField
_80
_80
import arrow
_80
_80
_80
@python_2_unicode_compatible
_80
class Appointment(models.Model):
_80
name = models.CharField(max_length=150)
_80
phone_number = models.CharField(max_length=15)
_80
time = models.DateTimeField()
_80
time_zone = TimeZoneField(default='UTC')
_80
_80
# Additional fields not visible to users
_80
task_id = models.CharField(max_length=50, blank=True, editable=False)
_80
created = models.DateTimeField(auto_now_add=True)
_80
_80
def __str__(self):
_80
return 'Appointment #{0} - {1}'.format(self.pk, self.name)
_80
_80
def get_absolute_url(self):
_80
return reverse('reminders:view_appointment', args=[str(self.id)])
_80
_80
def clean(self):
_80
"""Checks that appointments are not scheduled in the past"""
_80
_80
appointment_time = arrow.get(self.time, self.time_zone.zone)
_80
_80
if appointment_time < arrow.utcnow():
_80
raise ValidationError(
_80
'You cannot schedule an appointment for the past. '
_80
'Please check your time and time_zone')
_80
_80
def schedule_reminder(self):
_80
"""Schedule a Dramatiq task to send a reminder for this appointment"""
_80
_80
# Calculate the correct time to send this reminder
_80
appointment_time = arrow.get(self.time, self.time_zone.zone)
_80
reminder_time = appointment_time.shift(minutes=-30)
_80
now = arrow.now(self.time_zone.zone)
_80
milli_to_wait = int(
_80
(reminder_time - now).total_seconds()) * 1000
_80
_80
# Schedule the Dramatiq task
_80
from .tasks import send_sms_reminder
_80
result = send_sms_reminder.send_with_options(
_80
args=(self.pk,),
_80
delay=milli_to_wait)
_80
_80
return result.options['redis_message_id']
_80
_80
def save(self, *args, **kwargs):
_80
"""Custom save method which also schedules a reminder"""
_80
_80
# Check if we have scheduled a reminder for this appointment before
_80
if self.task_id:
_80
# Revoke that task in case its time has changed
_80
self.cancel_task()
_80
_80
# Save our appointment, which populates self.pk,
_80
# which is used in schedule_reminder
_80
super(Appointment, self).save(*args, **kwargs)
_80
_80
# Schedule a new reminder task for this appointment
_80
self.task_id = self.schedule_reminder()
_80
_80
# Save our appointment again, with the new task_id
_80
super(Appointment, self).save(*args, **kwargs)
_80
_80
def cancel_task(self):
_80
redis_client = redis.Redis(host=settings.REDIS_LOCAL, port=6379, db=0)
_80
redis_client.hdel("dramatiq:default.DQ.msgs", self.task_id)

We are now able to create new appointments. Nex, let's quickly implement a few other basic features: listing, updating, and deleting appointments.


Interacting with Appointments

interacting-with-appointments page anchor

As a user, I want to view a list of all future appointments, and be able to edit and delete those appointments.

If you're an organization that handles a lot of appointments, you probably want to be able to view and manage them in a single interface. That's what we'll tackle in this user story. We'll create a UI to:

  • Show all appointments
  • Edit individual appointments
  • Delete individual appointments

Because these are basic CRUD-like(link takes you to an external page) operations, we'll keep using Django's generic class-based views(link takes you to an external page) to save us a lot of work.

Interacting with appointments

interacting-with-appointments-1 page anchor

reminders/views.py


_44
from django.contrib.messages.views import SuccessMessageMixin
_44
from django.urls import reverse_lazy
_44
from django.views.generic import DetailView
_44
from django.views.generic.edit import CreateView
_44
from django.views.generic.edit import DeleteView
_44
from django.views.generic.edit import UpdateView
_44
from django.views.generic.list import ListView
_44
_44
from .models import Appointment
_44
_44
_44
class AppointmentListView(ListView):
_44
"""Shows users a list of appointments"""
_44
_44
model = Appointment
_44
_44
_44
class AppointmentDetailView(DetailView):
_44
"""Shows users a single appointment"""
_44
_44
model = Appointment
_44
_44
_44
class AppointmentCreateView(SuccessMessageMixin, CreateView):
_44
"""Powers a form to create a new appointment"""
_44
_44
model = Appointment
_44
fields = ['name', 'phone_number', 'time', 'time_zone']
_44
success_message = 'Appointment successfully created.'
_44
_44
_44
class AppointmentUpdateView(SuccessMessageMixin, UpdateView):
_44
"""Powers a form to edit existing appointments"""
_44
_44
model = Appointment
_44
fields = ['name', 'phone_number', 'time', 'time_zone']
_44
success_message = 'Appointment successfully updated.'
_44
_44
_44
class AppointmentDeleteView(DeleteView):
_44
"""Prompts users to confirm deletion of an appointment"""
_44
_44
model = Appointment
_44
success_url = reverse_lazy('list_appointments')

We have the high level view of the task, so let's start with listing all the upcoming appointments.


Showing a List of Appointments

showing-a-list-of-appointments page anchor

Django's ListView(link takes you to an external page) class was born for this.

All we need to do it's to point it at our Appointment model and it will handle building a QuerySet of all appointments for us.

And wiring up this view in our reminders/urls.py module is just as easy as our AppointmentCreateView:


_10
from .views import AppointmentListView
_10
_10
re_path(r'^$', AppointmentListView.as_view(), name='list_appointments'),

reminders/views.py


_44
from django.contrib.messages.views import SuccessMessageMixin
_44
from django.urls import reverse_lazy
_44
from django.views.generic import DetailView
_44
from django.views.generic.edit import CreateView
_44
from django.views.generic.edit import DeleteView
_44
from django.views.generic.edit import UpdateView
_44
from django.views.generic.list import ListView
_44
_44
from .models import Appointment
_44
_44
_44
class AppointmentListView(ListView):
_44
"""Shows users a list of appointments"""
_44
_44
model = Appointment
_44
_44
_44
class AppointmentDetailView(DetailView):
_44
"""Shows users a single appointment"""
_44
_44
model = Appointment
_44
_44
_44
class AppointmentCreateView(SuccessMessageMixin, CreateView):
_44
"""Powers a form to create a new appointment"""
_44
_44
model = Appointment
_44
fields = ['name', 'phone_number', 'time', 'time_zone']
_44
success_message = 'Appointment successfully created.'
_44
_44
_44
class AppointmentUpdateView(SuccessMessageMixin, UpdateView):
_44
"""Powers a form to edit existing appointments"""
_44
_44
model = Appointment
_44
fields = ['name', 'phone_number', 'time', 'time_zone']
_44
success_message = 'Appointment successfully updated.'
_44
_44
_44
class AppointmentDeleteView(DeleteView):
_44
"""Prompts users to confirm deletion of an appointment"""
_44
_44
model = Appointment
_44
success_url = reverse_lazy('list_appointments')

Our view is ready, now let's check out the template to display this list of appointments.


Appointment List Template

appointment-list-template page anchor

Our AppointmentListView passes its list of appointment objects to our template in the object_list variable.

If that variable is empty, we include a <p> tag saying there are no upcoming appointments.

Otherwise we populate a table with a row for each appointment in our list. We can use our handy get_absolute_url method again to include a link to each appointment's detail page.

We also use the {% url %}(link takes you to an external page) template tag to include links to our edit and delete views.

templates/reminders/appointment_list.html


_48
{% extends "base.html" %}
_48
_48
{% block title %}Upcoming reminders{% endblock title %}
_48
_48
{% block content %}
_48
<div class="row">
_48
<div class="col-lg-9">
_48
_48
<div class="page-header">
_48
<h1>Appointments</h1>
_48
</div>
_48
_48
{% if not object_list %}
_48
<p><strong>No upcoming appointments.</strong> Why not <a href="{% url 'new_appointment' %}">schedule one?</a>
_48
{% endif %}
_48
_48
<table class="table table-striped">
_48
<thead>
_48
<tr>
_48
<th>Id</th>
_48
<th>Name</th>
_48
<th>Phone number</th>
_48
<th>Time</th>
_48
<th>Created at</th>
_48
<th>Actions</th>
_48
</tr>
_48
</thead>
_48
<tbody>
_48
{% for appointment in object_list %}
_48
<tr>
_48
<td><a href="{{ appointment.get_absolute_url }}">{{ appointment.pk }}</a></td>
_48
<td>{{ appointment.name }}</td>
_48
<td>{{ appointment.phone_number }}</td>
_48
<td>{{ appointment.time }}</td>
_48
<td>{{ appointment.created }}</td>
_48
<td>
_48
<a class="btn btn-default btn-xs" href="{% url 'edit_appointment' appointment.pk %}">Edit</a>
_48
<a class="btn btn-xs btn-danger" href="{% url 'delete_appointment' appointment.pk %}">Delete</a>
_48
</td>
_48
</tr>
_48
{% endfor %}
_48
</tbody>
_48
</table>
_48
_48
<a class="btn btn-primary" href="{% url 'new_appointment' %}">New</a>
_48
</div>
_48
</div>
_48
\{% endblock %}

And now that our appointment listing requirement is complete, let's see how we can use the new Appointment form to update exisiting appointments.


Tweaking our Form Template

tweaking-our-form-template page anchor

Django's UpdateView(link takes you to an external page) makes it easy to add a view for updating appointments. Our form template needs a few tweaks, though, to handle prepopulated data from an existing appointment.

Django will store our datetimes precisely, down to the second, but we don't want to bother our users by forcing them to pick the precise second an appointment starts.

To fix this problem we use the extraFormats configuration option(link takes you to an external page) of bootstrap-datetimepicker.

By configuring our datetimepicker with a format value that doesn't ask users for seconds, and an extraFormat value that does accept datetimes with seconds, our form will populate correctly when Django provides a full datetime to our template.

Tweaking new Appointment form

tweaking-new-appointment-form page anchor

templates/reminders/appointment_form.html


_57
{% extends "base.html" %}
_57
_57
{% load bootstrap_tags %}
_57
_57
{% block title %}New reminder{% endblock title %}
_57
_57
{% block content %}
_57
<div class="row">
_57
<div class="col-lg-9">
_57
<div class="page-header">
_57
<h1>
_57
{% if not object.pk %}
_57
New appointment
_57
{% else %}
_57
Edit appointment
_57
{% endif %}
_57
</h1>
_57
</div>
_57
_57
<form class="form-horizontal" method="post">
_57
{% csrf_token %}
_57
{{ form|as_bootstrap_horizontal }}
_57
<div class="form-group">
_57
<div class="col-sm-offset-2 col-sm-10">
_57
<a href="#back" class="btn btn-default">Cancel</a>
_57
<button type="submit" class="btn btn-primary">
_57
{% if not object.pk %}
_57
Create appointment
_57
{% else %}
_57
Update appointment
_57
{% endif %}
_57
</button>
_57
</div>
_57
</div>
_57
</form>
_57
</div>
_57
</div>
_57
{% endblock %}
_57
_57
{% block page_css %}
_57
<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/4.7.14/css/bootstrap-datetimepicker.min.css" />
_57
{% endblock %}
_57
_57
{% block page_js %}
_57
<script src="//cdnjs.cloudflare.com/ajax/libs/moment.js/2.10.3/moment.min.js"></script>
_57
<script src="//cdnjs.cloudflare.com/ajax/libs/bootstrap-datetimepicker/4.7.14/js/bootstrap-datetimepicker.min.js"></script>
_57
_57
<script type="text/javascript">
_57
$(function() {
_57
$('#id_time').datetimepicker({
_57
format: 'MM/DD/YYYY HH:mm',
_57
extraFormats: ['YYYY-MM-DD HH:mm:ss'],
_57
sideBySide: true
_57
});
_57
});
_57
</script>
_57
{% endblock %}

We now have everything to List, Create and Update an Appointment. All that is left is handle the Delete.


DeleteView(link takes you to an external page) is an especially handy view class. It shows users a confirmation page before deleting the specified object.

Like UpdateView, DeleteView finds the object to delete by using the pk parameter in its URL, declared in reminders/urls.py:


_10
from .views import AppointmentDeleteView
_10
_10
re_path(r'^/(?P[0-9]+)/delete$', AppointmentDeleteView.as_view(), name='delete_appointment'),

We also need to specify a success_url property on our view class. This property tells Django where to send users after a successful deletion. In our case, we'll send them back to the list of appointments at the URL named list_appointments.

When a Django project starts running, it evaluates views before URLs, so we need to use the reverse_lazy(link takes you to an external page) utility function to get our appointment list URL instead of reverse.

By default, our AppointmentDeleteView will look for a template named appointment_confirm_delete.html. You can check out ours in the templates/reminders directory.

And that closes out this user story.

reminders/views.py


_44
from django.contrib.messages.views import SuccessMessageMixin
_44
from django.urls import reverse_lazy
_44
from django.views.generic import DetailView
_44
from django.views.generic.edit import CreateView
_44
from django.views.generic.edit import DeleteView
_44
from django.views.generic.edit import UpdateView
_44
from django.views.generic.list import ListView
_44
_44
from .models import Appointment
_44
_44
_44
class AppointmentListView(ListView):
_44
"""Shows users a list of appointments"""
_44
_44
model = Appointment
_44
_44
_44
class AppointmentDetailView(DetailView):
_44
"""Shows users a single appointment"""
_44
_44
model = Appointment
_44
_44
_44
class AppointmentCreateView(SuccessMessageMixin, CreateView):
_44
"""Powers a form to create a new appointment"""
_44
_44
model = Appointment
_44
fields = ['name', 'phone_number', 'time', 'time_zone']
_44
success_message = 'Appointment successfully created.'
_44
_44
_44
class AppointmentUpdateView(SuccessMessageMixin, UpdateView):
_44
"""Powers a form to edit existing appointments"""
_44
_44
model = Appointment
_44
fields = ['name', 'phone_number', 'time', 'time_zone']
_44
success_message = 'Appointment successfully updated.'
_44
_44
_44
class AppointmentDeleteView(DeleteView):
_44
"""Prompts users to confirm deletion of an appointment"""
_44
_44
model = Appointment
_44
success_url = reverse_lazy('list_appointments')

Our users now have everything they need to manage appointments - all that's left to implement is sending the reminders.


As an appointment system, I want to notify a customer via SMS an arbitrary interval before a future appointment.

To satisfy this user story, we need to make our application work asynchronously - on its own independent of any individual user interaction.

One of the most popular Python library for asynchronous tasks is Dramatiq(link takes you to an external page). To integrate Dramatiq with our application, we need to make a few changes:

  • Create a new function that sends an SMS message using information from an Appointment object
  • Register that function as a task with Dramatiq so it can be executed asynchronously
  • Run a separate Dramatiq worker process alongside our Django application to call our SMS reminder function at the right time for each appointment

If you're brand new to Dramatiq, you might want to skim its Introduction to Dramatiq(link takes you to an external page) page before proceeding.

reminders/tasks.py


_38
from __future__ import absolute_import
_38
_38
import arrow
_38
import dramatiq
_38
_38
from django.conf import settings
_38
from twilio.rest import Client
_38
_38
from .models import Appointment
_38
_38
_38
# Uses credentials from the TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN
_38
# environment variables
_38
client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN)
_38
_38
_38
@dramatiq.actor
_38
def send_sms_reminder(appointment_id):
_38
"""Send a reminder to a phone using Twilio SMS"""
_38
# Get our appointment from the database
_38
try:
_38
appointment = Appointment.objects.get(pk=appointment_id)
_38
except Appointment.DoesNotExist:
_38
# The appointment we were trying to remind someone about
_38
# has been deleted, so we don't need to do anything
_38
return
_38
_38
appointment_time = arrow.get(appointment.time, appointment.time_zone.zone)
_38
body = 'Hi {0}. You have an appointment coming up at {1}.'.format(
_38
appointment.name,
_38
appointment_time.format('h:mm a')
_38
)
_38
_38
client.messages.create(
_38
body=body,
_38
to=appointment.phone_number,
_38
from_=settings.TWILIO_NUMBER,
_38
)

Next we will configure Dramatiq to work with our project.


Dramatiq and Django are both big Python projects, but they can work together easily.

By following the instructions in the Dramatiq docs(link takes you to an external page), we can include our Dramatiq settings in our Django settings modules. We can also write our Dramatiq tasks in tasks.py modules that live inside our Django apps, which keeps our project layout consistent and simple.

To use Dramatiq, you also need a separate service to be your message broker. We used Redis(link takes you to an external page) for this project.

The Dramatiq-specific settings in our common.py settings module is DRAMATIQ_BROKER.

If you want to see all the steps to get Django, Dramatiq, Redis, and Postgres working on your machine check out the README for this project on GitHub(link takes you to an external page).

appointments/settings/common.py


_138
"""
_138
Common Django settings for the appointments project.
_138
_138
See the local, test, and production settings modules for the values used
_138
in each environment.
_138
_138
For more information on this file, see
_138
https://docs.djangoproject.com/en/1.8/topics/settings/
_138
_138
For the full list of settings and their values, see
_138
https://docs.djangoproject.com/en/1.8/ref/settings/
_138
"""
_138
_138
import os
_138
_138
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_138
_138
# SECURITY WARNING: don't run with debug turned on in production!
_138
DEBUG = False
_138
_138
# SECURITY WARNING: keep the secret key used in production secret!
_138
SECRET_KEY = 'not-so-secret'
_138
_138
# Twilio API
_138
TWILIO_NUMBER = os.environ.get('TWILIO_NUMBER')
_138
TWILIO_ACCOUNT_SID = os.environ.get('TWILIO_ACCOUNT_SID')
_138
TWILIO_AUTH_TOKEN = os.environ.get('TWILIO_AUTH_TOKEN')
_138
_138
DRAMATIQ_BROKER = {
_138
"BROKER": "dramatiq.brokers.redis.RedisBroker",
_138
"OPTIONS": {
_138
"url": 'redis://localhost:6379/0',
_138
},
_138
"MIDDLEWARE": [
_138
"dramatiq.middleware.Prometheus",
_138
"dramatiq.middleware.AgeLimit",
_138
"dramatiq.middleware.TimeLimit",
_138
"dramatiq.middleware.Callbacks",
_138
"dramatiq.middleware.Retries",
_138
"django_dramatiq.middleware.AdminMiddleware",
_138
"django_dramatiq.middleware.DbConnectionsMiddleware",
_138
]
_138
}
_138
_138
# Reminder time: how early text messages are sent in advance of appointments
_138
REMINDER_TIME = 30 # minutes
_138
_138
ALLOWED_HOSTS = []
_138
_138
# Application definition
_138
_138
DJANGO_APPS = (
_138
'django_dramatiq',
_138
'django.contrib.admin',
_138
'django.contrib.auth',
_138
'django.contrib.contenttypes',
_138
'django.contrib.sessions',
_138
'django.contrib.messages',
_138
'django.contrib.staticfiles'
_138
)
_138
_138
THIRD_PARTY_APPS = (
_138
'bootstrap3',
_138
'django_forms_bootstrap',
_138
'timezone_field'
_138
)
_138
_138
LOCAL_APPS = (
_138
'reminders',
_138
)
_138
_138
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
_138
_138
MIDDLEWARE = (
_138
'django.contrib.sessions.middleware.SessionMiddleware',
_138
'django.middleware.common.CommonMiddleware',
_138
'django.middleware.csrf.CsrfViewMiddleware',
_138
'django.contrib.auth.middleware.AuthenticationMiddleware',
_138
'django.contrib.messages.middleware.MessageMiddleware',
_138
'django.middleware.clickjacking.XFrameOptionsMiddleware',
_138
'django.middleware.security.SecurityMiddleware',
_138
'whitenoise.middleware.WhiteNoiseMiddleware',
_138
)
_138
_138
ROOT_URLCONF = 'appointments.urls'
_138
_138
TEMPLATES = [
_138
{
_138
'BACKEND': 'django.template.backends.django.DjangoTemplates',
_138
'DIRS': ['templates/'],
_138
'APP_DIRS': True,
_138
'OPTIONS': {
_138
'context_processors': [
_138
'django.template.context_processors.debug',
_138
'django.template.context_processors.request',
_138
'django.contrib.auth.context_processors.auth',
_138
'django.contrib.messages.context_processors.messages',
_138
],
_138
},
_138
},
_138
]
_138
_138
CRISPY_TEMPLATE_PACK = 'bootstrap3'
_138
_138
WSGI_APPLICATION = 'appointments.wsgi.application'
_138
_138
_138
# Database
_138
# https://docs.djangoproject.com/en/1.8/ref/settings/#databases
_138
_138
DATABASES = {
_138
'default': {
_138
'ENGINE': 'django.db.backends.postgresql_psycopg2',
_138
'NAME': 'appointment_reminders'
_138
}
_138
}
_138
_138
_138
# Internationalization
_138
# https://docs.djangoproject.com/en/1.8/topics/i18n/
_138
_138
LANGUAGE_CODE = 'en-us'
_138
_138
TIME_ZONE = 'UTC'
_138
_138
USE_I18N = True
_138
_138
USE_L10N = True
_138
_138
USE_TZ = True
_138
_138
_138
# Static files (CSS, JavaScript, Images)
_138
# https://docs.djangoproject.com/en/1.8/howto/static-files/
_138
_138
STATIC_ROOT = BASE_DIR + '/staticfiles'
_138
_138
STATIC_URL = '/static/'

Now that Dramatiq is working with our project, it's time to write a new task for sending a customer an SMS message about their appointment.


Creating a Dramatiq task

creating-a-dramatiq-task page anchor

Our task takes an appointment's ID - it's primary key - as its only argument. We could pass the Appointment object itself as the argument, but this best practice ensures our SMS will use the most up-to-date version of our appointment's data.

It also gives us an opportunity to check if the appointment has been deleted before the reminder was sent, which we do at the top of our function. This way we won't send SMS reminders for appointments that don't exist anymore.

Fetch appointments on Dramatiq task

fetch-appointments-on-dramatiq-task page anchor

reminders/tasks.py


_38
from __future__ import absolute_import
_38
_38
import arrow
_38
import dramatiq
_38
_38
from django.conf import settings
_38
from twilio.rest import Client
_38
_38
from .models import Appointment
_38
_38
_38
# Uses credentials from the TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN
_38
# environment variables
_38
client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN)
_38
_38
_38
@dramatiq.actor
_38
def send_sms_reminder(appointment_id):
_38
"""Send a reminder to a phone using Twilio SMS"""
_38
# Get our appointment from the database
_38
try:
_38
appointment = Appointment.objects.get(pk=appointment_id)
_38
except Appointment.DoesNotExist:
_38
# The appointment we were trying to remind someone about
_38
# has been deleted, so we don't need to do anything
_38
return
_38
_38
appointment_time = arrow.get(appointment.time, appointment.time_zone.zone)
_38
body = 'Hi {0}. You have an appointment coming up at {1}.'.format(
_38
appointment.name,
_38
appointment_time.format('h:mm a')
_38
)
_38
_38
client.messages.create(
_38
body=body,
_38
to=appointment.phone_number,
_38
from_=settings.TWILIO_NUMBER,
_38
)

Let's stay in our task a bit longer, because the next step is to compose the text of our SMS message.


We use the handy arrow(link takes you to an external page) library to format our appointment's time. After that, we use the twilio-python(link takes you to an external page) library to send our message.

We instantiate a Twilio REST client at the top of the module, which looks for TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN environment variables to authenticate itself. You can find the correct values for you in your account dashboard.

Sending the SMS message itself is as easy as calling client.messages.create(), passing arguments for the body of the SMS message, the recipient's phone number, and the Twilio phone number you want to send this message from. Twilio will deliver the SMS message immediately.

Send SMS on Dramatiq task

send-sms-on-dramatiq-task page anchor

reminders/tasks.py


_38
from __future__ import absolute_import
_38
_38
import arrow
_38
import dramatiq
_38
_38
from django.conf import settings
_38
from twilio.rest import Client
_38
_38
from .models import Appointment
_38
_38
_38
# Uses credentials from the TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN
_38
# environment variables
_38
client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN)
_38
_38
_38
@dramatiq.actor
_38
def send_sms_reminder(appointment_id):
_38
"""Send a reminder to a phone using Twilio SMS"""
_38
# Get our appointment from the database
_38
try:
_38
appointment = Appointment.objects.get(pk=appointment_id)
_38
except Appointment.DoesNotExist:
_38
# The appointment we were trying to remind someone about
_38
# has been deleted, so we don't need to do anything
_38
return
_38
_38
appointment_time = arrow.get(appointment.time, appointment.time_zone.zone)
_38
body = 'Hi {0}. You have an appointment coming up at {1}.'.format(
_38
appointment.name,
_38
appointment_time.format('h:mm a')
_38
)
_38
_38
client.messages.create(
_38
body=body,
_38
to=appointment.phone_number,
_38
from_=settings.TWILIO_NUMBER,
_38
)

With our send_sms_reminder task complete, let's look at how to call it when our appointments are created or updated.


Calling our Reminder Task

calling-our-reminder-task page anchor

We added a new method on our Appointment model to help schedule a reminder for an individual appointment.

Our method starts by using arrow(link takes you to an external page) again to build a new datetime with the appointment's time and time_zone.

Moving backward in time can be tricky in normal Python, but arrow's .replace() method lets us easily subtract minutes from our appointment_time. The REMINDER_TIME setting defaults to 30 minutes.

We finish by invoking our Dramatiq task, using the delay(link takes you to an external page) parameter to tell Dramatiq when this task should execute.

We can't import the send_sms_reminder task at the top of our models.py module because the tasks.py module imports the Appointment model. Importing it in our schedule_reminder method avoids a circular dependency.

Schedule a new Dramatiq task

schedule-a-new-dramatiq-task page anchor

reminders/models.py


_80
from __future__ import unicode_literals
_80
_80
import redis
_80
_80
from django.core.exceptions import ValidationError
_80
from django.conf import settings
_80
from django.db import models
_80
from django.urls import reverse
_80
from six import python_2_unicode_compatible
_80
from timezone_field import TimeZoneField
_80
_80
import arrow
_80
_80
_80
@python_2_unicode_compatible
_80
class Appointment(models.Model):
_80
name = models.CharField(max_length=150)
_80
phone_number = models.CharField(max_length=15)
_80
time = models.DateTimeField()
_80
time_zone = TimeZoneField(default='UTC')
_80
_80
# Additional fields not visible to users
_80
task_id = models.CharField(max_length=50, blank=True, editable=False)
_80
created = models.DateTimeField(auto_now_add=True)
_80
_80
def __str__(self):
_80
return 'Appointment #{0} - {1}'.format(self.pk, self.name)
_80
_80
def get_absolute_url(self):
_80
return reverse('reminders:view_appointment', args=[str(self.id)])
_80
_80
def clean(self):
_80
"""Checks that appointments are not scheduled in the past"""
_80
_80
appointment_time = arrow.get(self.time, self.time_zone.zone)
_80
_80
if appointment_time < arrow.utcnow():
_80
raise ValidationError(
_80
'You cannot schedule an appointment for the past. '
_80
'Please check your time and time_zone')
_80
_80
def schedule_reminder(self):
_80
"""Schedule a Dramatiq task to send a reminder for this appointment"""
_80
_80
# Calculate the correct time to send this reminder
_80
appointment_time = arrow.get(self.time, self.time_zone.zone)
_80
reminder_time = appointment_time.shift(minutes=-30)
_80
now = arrow.now(self.time_zone.zone)
_80
milli_to_wait = int(
_80
(reminder_time - now).total_seconds()) * 1000
_80
_80
# Schedule the Dramatiq task
_80
from .tasks import send_sms_reminder
_80
result = send_sms_reminder.send_with_options(
_80
args=(self.pk,),
_80
delay=milli_to_wait)
_80
_80
return result.options['redis_message_id']
_80
_80
def save(self, *args, **kwargs):
_80
"""Custom save method which also schedules a reminder"""
_80
_80
# Check if we have scheduled a reminder for this appointment before
_80
if self.task_id:
_80
# Revoke that task in case its time has changed
_80
self.cancel_task()
_80
_80
# Save our appointment, which populates self.pk,
_80
# which is used in schedule_reminder
_80
super(Appointment, self).save(*args, **kwargs)
_80
_80
# Schedule a new reminder task for this appointment
_80
self.task_id = self.schedule_reminder()
_80
_80
# Save our appointment again, with the new task_id
_80
super(Appointment, self).save(*args, **kwargs)
_80
_80
def cancel_task(self):
_80
redis_client = redis.Redis(host=settings.REDIS_LOCAL, port=6379, db=0)
_80
redis_client.hdel("dramatiq:default.DQ.msgs", self.task_id)

The last thing we need to do is ensure Django calls our schedule_reminder method every time an Appointment object is created or updated.


Overriding the Appointment Save Method

overriding-the-appointment-save-method page anchor

The best way to do that is to override our model's save method(link takes you to an external page), including an extra call to schedule_reminder after the object's primary key has been assigned.

Avoiding duplicate or mistimed reminders

Scheduling a Dramatiq task every time an appointment is saved has an unfortunate side effect - our customers will receive duplicate reminders if an appointment was saved more than once. And those reminders could be sent at the wrong time if an appointment's time field was changed after its creation.

To fix this, we keep track of each appointment's reminder task through the task_id field, which stores Dramatiq's unique identifier for each task.

We then look for a previously scheduled task at the top of our custom save method and cancel it if present.

This guarantees that one and exactly one reminder will be sent for each appointment in our database, and that it will be sent at the most recent time provided for that appointment.

Overriden save() method to call schedule_reminder()

overriden-save-method-to-call-schedule_reminder page anchor

reminders/models.py


_80
from __future__ import unicode_literals
_80
_80
import redis
_80
_80
from django.core.exceptions import ValidationError
_80
from django.conf import settings
_80
from django.db import models
_80
from django.urls import reverse
_80
from six import python_2_unicode_compatible
_80
from timezone_field import TimeZoneField
_80
_80
import arrow
_80
_80
_80
@python_2_unicode_compatible
_80
class Appointment(models.Model):
_80
name = models.CharField(max_length=150)
_80
phone_number = models.CharField(max_length=15)
_80
time = models.DateTimeField()
_80
time_zone = TimeZoneField(default='UTC')
_80
_80
# Additional fields not visible to users
_80
task_id = models.CharField(max_length=50, blank=True, editable=False)
_80
created = models.DateTimeField(auto_now_add=True)
_80
_80
def __str__(self):
_80
return 'Appointment #{0} - {1}'.format(self.pk, self.name)
_80
_80
def get_absolute_url(self):
_80
return reverse('reminders:view_appointment', args=[str(self.id)])
_80
_80
def clean(self):
_80
"""Checks that appointments are not scheduled in the past"""
_80
_80
appointment_time = arrow.get(self.time, self.time_zone.zone)
_80
_80
if appointment_time < arrow.utcnow():
_80
raise ValidationError(
_80
'You cannot schedule an appointment for the past. '
_80
'Please check your time and time_zone')
_80
_80
def schedule_reminder(self):
_80
"""Schedule a Dramatiq task to send a reminder for this appointment"""
_80
_80
# Calculate the correct time to send this reminder
_80
appointment_time = arrow.get(self.time, self.time_zone.zone)
_80
reminder_time = appointment_time.shift(minutes=-30)
_80
now = arrow.now(self.time_zone.zone)
_80
milli_to_wait = int(
_80
(reminder_time - now).total_seconds()) * 1000
_80
_80
# Schedule the Dramatiq task
_80
from .tasks import send_sms_reminder
_80
result = send_sms_reminder.send_with_options(
_80
args=(self.pk,),
_80
delay=milli_to_wait)
_80
_80
return result.options['redis_message_id']
_80
_80
def save(self, *args, **kwargs):
_80
"""Custom save method which also schedules a reminder"""
_80
_80
# Check if we have scheduled a reminder for this appointment before
_80
if self.task_id:
_80
# Revoke that task in case its time has changed
_80
self.cancel_task()
_80
_80
# Save our appointment, which populates self.pk,
_80
# which is used in schedule_reminder
_80
super(Appointment, self).save(*args, **kwargs)
_80
_80
# Schedule a new reminder task for this appointment
_80
self.task_id = self.schedule_reminder()
_80
_80
# Save our appointment again, with the new task_id
_80
super(Appointment, self).save(*args, **kwargs)
_80
_80
def cancel_task(self):
_80
redis_client = redis.Redis(host=settings.REDIS_LOCAL, port=6379, db=0)
_80
redis_client.hdel("dramatiq:default.DQ.msgs", self.task_id)

Fun tutorial, right? Where can we take it from here?


Finishing the Django Appointment Reminder Implementation

finishing-the-django-appointment-reminder-implementation page anchor

We used Django's class-based views(link takes you to an external page) to help us quickly build out the features to support simple CRUD operations on our Appointment model.

We then integrated Dramatiq(link takes you to an external page) into our project and used the twilio-python(link takes you to an external page) helper library to send SMS reminders about our appointments asynchronously.

You'll find instructions to run this project locally in its GitHub README(link takes you to an external page).

reminders/models.py


_80
from __future__ import unicode_literals
_80
_80
import redis
_80
_80
from django.core.exceptions import ValidationError
_80
from django.conf import settings
_80
from django.db import models
_80
from django.urls import reverse
_80
from six import python_2_unicode_compatible
_80
from timezone_field import TimeZoneField
_80
_80
import arrow
_80
_80
_80
@python_2_unicode_compatible
_80
class Appointment(models.Model):
_80
name = models.CharField(max_length=150)
_80
phone_number = models.CharField(max_length=15)
_80
time = models.DateTimeField()
_80
time_zone = TimeZoneField(default='UTC')
_80
_80
# Additional fields not visible to users
_80
task_id = models.CharField(max_length=50, blank=True, editable=False)
_80
created = models.DateTimeField(auto_now_add=True)
_80
_80
def __str__(self):
_80
return 'Appointment #{0} - {1}'.format(self.pk, self.name)
_80
_80
def get_absolute_url(self):
_80
return reverse('reminders:view_appointment', args=[str(self.id)])
_80
_80
def clean(self):
_80
"""Checks that appointments are not scheduled in the past"""
_80
_80
appointment_time = arrow.get(self.time, self.time_zone.zone)
_80
_80
if appointment_time < arrow.utcnow():
_80
raise ValidationError(
_80
'You cannot schedule an appointment for the past. '
_80
'Please check your time and time_zone')
_80
_80
def schedule_reminder(self):
_80
"""Schedule a Dramatiq task to send a reminder for this appointment"""
_80
_80
# Calculate the correct time to send this reminder
_80
appointment_time = arrow.get(self.time, self.time_zone.zone)
_80
reminder_time = appointment_time.shift(minutes=-30)
_80
now = arrow.now(self.time_zone.zone)
_80
milli_to_wait = int(
_80
(reminder_time - now).total_seconds()) * 1000
_80
_80
# Schedule the Dramatiq task
_80
from .tasks import send_sms_reminder
_80
result = send_sms_reminder.send_with_options(
_80
args=(self.pk,),
_80
delay=milli_to_wait)
_80
_80
return result.options['redis_message_id']
_80
_80
def save(self, *args, **kwargs):
_80
"""Custom save method which also schedules a reminder"""
_80
_80
# Check if we have scheduled a reminder for this appointment before
_80
if self.task_id:
_80
# Revoke that task in case its time has changed
_80
self.cancel_task()
_80
_80
# Save our appointment, which populates self.pk,
_80
# which is used in schedule_reminder
_80
super(Appointment, self).save(*args, **kwargs)
_80
_80
# Schedule a new reminder task for this appointment
_80
self.task_id = self.schedule_reminder()
_80
_80
# Save our appointment again, with the new task_id
_80
super(Appointment, self).save(*args, **kwargs)
_80
_80
def cancel_task(self):
_80
redis_client = redis.Redis(host=settings.REDIS_LOCAL, port=6379, db=0)
_80
redis_client.hdel("dramatiq:default.DQ.msgs", self.task_id)


And with a little code and a dash of configuration, we're ready to get automated appointment reminders firing in our application. Good work!

If you are a Python developer working with Twilio, you might want to check out the following resources for Python:

  • Browser Call : Put a button on your web page that connects visitors to live support or sales people via telephone.
  • Verify Python Quickstart : Verify phone numbers and add an additional layer of security to your Python app by using Twilio Verify .

Did this help?

did-this-help page anchor

Thanks for checking out this tutorial! If you have any feedback to share with us, please reach out on Twitter(link takes you to an external page)... we'd love to hear your thoughts, and know what you're building!


Rate this page: