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:
Check out how Yelp uses SMS to confirm restaurant reservations for diners.
Here are the technologies we'll use:
To implement appointment reminders, we will be working through a series of user stories 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.
We're building this app for Django 2.1 on Python 3.7. We're big fans of Two Scoops of Django 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 for our database and Redis as our Dramatiq message broker.
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:
Appointment
model to store information we need to send the reminder
POST
data from it
Alright, so we know what we need to create a new appointment. 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:
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.
reminders/models.py
_80from __future__ import unicode_literals_80_80import redis_80_80from django.core.exceptions import ValidationError_80from django.conf import settings_80from django.db import models_80from django.urls import reverse_80from six import python_2_unicode_compatible_80from timezone_field import TimeZoneField_80_80import arrow_80_80_80@python_2_unicode_compatible_80class 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 or classes.
Class-based views are great when your views need to support CRUD-like features - perfect for our appointments project.
To make a view for creating new Appointment
objects, we'll use Django's generic CreateView 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 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.
This mixin tells our view to pass the success_message
property of our class to the Django messages framework after a successful creation. We will display those messages to the user in our templates.
reminders/views.py
_44from django.contrib.messages.views import SuccessMessageMixin_44from django.urls import reverse_lazy_44from django.views.generic import DetailView_44from django.views.generic.edit import CreateView_44from django.views.generic.edit import DeleteView_44from django.views.generic.edit import UpdateView_44from django.views.generic.list import ListView_44_44from .models import Appointment_44_44_44class AppointmentListView(ListView):_44 """Shows users a list of appointments"""_44_44 model = Appointment_44_44_44class AppointmentDetailView(DetailView):_44 """Shows users a single appointment"""_44_44 model = Appointment_44_44_44class 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_44class 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_44class 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.
reminders/urls.py
_26from django.conf.urls import re_path_26_26from .views import (_26 AppointmentCreateView,_26 AppointmentDeleteView,_26 AppointmentDetailView,_26 AppointmentListView,_26 AppointmentUpdateView,_26)_26_26urlpatterns = [_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 for the front end of our app, and we use the django-forms-bootstrap 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.
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 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.
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 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 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
_80from __future__ import unicode_literals_80_80import redis_80_80from django.core.exceptions import ValidationError_80from django.conf import settings_80from django.db import models_80from django.urls import reverse_80from six import python_2_unicode_compatible_80from timezone_field import TimeZoneField_80_80import arrow_80_80_80@python_2_unicode_compatible_80class 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.
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:
Because these are basic CRUD-like operations, we'll keep using Django's generic class-based views to save us a lot of work.
reminders/views.py
_44from django.contrib.messages.views import SuccessMessageMixin_44from django.urls import reverse_lazy_44from django.views.generic import DetailView_44from django.views.generic.edit import CreateView_44from django.views.generic.edit import DeleteView_44from django.views.generic.edit import UpdateView_44from django.views.generic.list import ListView_44_44from .models import Appointment_44_44_44class AppointmentListView(ListView):_44 """Shows users a list of appointments"""_44_44 model = Appointment_44_44_44class AppointmentDetailView(DetailView):_44 """Shows users a single appointment"""_44_44 model = Appointment_44_44_44class 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_44class 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_44class 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.
Django's ListView 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.
_10from .views import AppointmentListView_10_10re_path(r'^$', AppointmentListView.as_view(), name='list_appointments'),
reminders/views.py
_44from django.contrib.messages.views import SuccessMessageMixin_44from django.urls import reverse_lazy_44from django.views.generic import DetailView_44from django.views.generic.edit import CreateView_44from django.views.generic.edit import DeleteView_44from django.views.generic.edit import UpdateView_44from django.views.generic.list import ListView_44_44from .models import Appointment_44_44_44class AppointmentListView(ListView):_44 """Shows users a list of appointments"""_44_44 model = Appointment_44_44_44class AppointmentDetailView(DetailView):_44 """Shows users a single appointment"""_44_44 model = Appointment_44_44_44class 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_44class 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_44class 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.
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 %} 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 existing appointments.
Django's UpdateView allows you 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 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.
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 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
:
_10from .views import AppointmentDeleteView_10_10re_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 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
_44from django.contrib.messages.views import SuccessMessageMixin_44from django.urls import reverse_lazy_44from django.views.generic import DetailView_44from django.views.generic.edit import CreateView_44from django.views.generic.edit import DeleteView_44from django.views.generic.edit import UpdateView_44from django.views.generic.list import ListView_44_44from .models import Appointment_44_44_44class AppointmentListView(ListView):_44 """Shows users a list of appointments"""_44_44 model = Appointment_44_44_44class AppointmentDetailView(DetailView):_44 """Shows users a single appointment"""_44_44 model = Appointment_44_44_44class 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_44class 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_44class 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. To integrate Dramatiq with our application, we need to make a few changes:
Appointment
object
If you're brand new to Dramatiq, you might want to skim its Introduction to Dramatiq page before proceeding.
reminders/tasks.py
_38from __future__ import absolute_import_38_38import arrow_38import dramatiq_38_38from django.conf import settings_38from twilio.rest import Client_38_38from .models import Appointment_38_38_38# Uses credentials from the TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN_38# environment variables_38client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN)_38_38_38@dramatiq.actor_38def 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.
By following the instructions in the Dramatiq docs, 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 organized.
To use Dramatiq, you also need a separate service to be your message broker. We used Redis 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.
appointments/settings/common.py
_138"""_138Common Django settings for the appointments project._138_138See the local, test, and production settings modules for the values used_138in each environment._138_138For more information on this file, see_138https://docs.djangoproject.com/en/1.8/topics/settings/_138_138For the full list of settings and their values, see_138https://docs.djangoproject.com/en/1.8/ref/settings/_138"""_138_138import os_138_138BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))_138_138# SECURITY WARNING: don't run with debug turned on in production!_138DEBUG = False_138_138# SECURITY WARNING: keep the secret key used in production secret!_138SECRET_KEY = 'not-so-secret'_138_138# Twilio API_138TWILIO_NUMBER = os.environ.get('TWILIO_NUMBER')_138TWILIO_ACCOUNT_SID = os.environ.get('TWILIO_ACCOUNT_SID')_138TWILIO_AUTH_TOKEN = os.environ.get('TWILIO_AUTH_TOKEN')_138_138DRAMATIQ_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_138REMINDER_TIME = 30 # minutes_138_138ALLOWED_HOSTS = []_138_138# Application definition_138_138DJANGO_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_138THIRD_PARTY_APPS = (_138 'bootstrap3',_138 'django_forms_bootstrap',_138 'timezone_field'_138)_138_138LOCAL_APPS = (_138 'reminders',_138)_138_138INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS_138_138MIDDLEWARE = (_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_138ROOT_URLCONF = 'appointments.urls'_138_138TEMPLATES = [_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_138CRISPY_TEMPLATE_PACK = 'bootstrap3'_138_138WSGI_APPLICATION = 'appointments.wsgi.application'_138_138_138# Database_138# https://docs.djangoproject.com/en/1.8/ref/settings/#databases_138_138DATABASES = {_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_138LANGUAGE_CODE = 'en-us'_138_138TIME_ZONE = 'UTC'_138_138USE_I18N = True_138_138USE_L10N = True_138_138USE_TZ = True_138_138_138# Static files (CSS, JavaScript, Images)_138# https://docs.djangoproject.com/en/1.8/howto/static-files/_138_138STATIC_ROOT = BASE_DIR + '/staticfiles'_138_138STATIC_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.
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.
reminders/tasks.py
_38from __future__ import absolute_import_38_38import arrow_38import dramatiq_38_38from django.conf import settings_38from twilio.rest import Client_38_38from .models import Appointment_38_38_38# Uses credentials from the TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN_38# environment variables_38client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN)_38_38_38@dramatiq.actor_38def 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 library to format our appointment's time. After that, we use the twilio-python 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.
To send the SMS message itself, you'll call 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.
reminders/tasks.py
_38from __future__ import absolute_import_38_38import arrow_38import dramatiq_38_38from django.conf import settings_38from twilio.rest import Client_38_38from .models import Appointment_38_38_38# Uses credentials from the TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN_38# environment variables_38client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN)_38_38_38@dramatiq.actor_38def 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.
We added a new method on our Appointment
model to help schedule a reminder for an individual appointment.
Our method starts by using arrow 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 subtract minutes from our appointment_time
. The REMINDER_TIME
setting defaults to 30 minutes.
We finish by invoking our Dramatiq task, using the delay 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.
reminders/models.py
_80from __future__ import unicode_literals_80_80import redis_80_80from django.core.exceptions import ValidationError_80from django.conf import settings_80from django.db import models_80from django.urls import reverse_80from six import python_2_unicode_compatible_80from timezone_field import TimeZoneField_80_80import arrow_80_80_80@python_2_unicode_compatible_80class 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.
The best way to do that is to override our model's save method, 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.
reminders/models.py
_80from __future__ import unicode_literals_80_80import redis_80_80from django.core.exceptions import ValidationError_80from django.conf import settings_80from django.db import models_80from django.urls import reverse_80from six import python_2_unicode_compatible_80from timezone_field import TimeZoneField_80_80import arrow_80_80_80@python_2_unicode_compatible_80class 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?
We used Django's class-based views to help us build out the features to support CRUD operations on our Appointment
model.
We then integrated Dramatiq into our project and used the twilio-python helper library to send SMS reminders about our appointments asynchronously.
You'll find instructions to run this project locally in its GitHub README.
reminders/models.py
_80from __future__ import unicode_literals_80_80import redis_80_80from django.core.exceptions import ValidationError_80from django.conf import settings_80from django.db import models_80from django.urls import reverse_80from six import python_2_unicode_compatible_80from timezone_field import TimeZoneField_80_80import arrow_80_80_80@python_2_unicode_compatible_80class 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: