If you've built applications, inevitably you've been tasked with building in complex CRM functionality. If you've built out e-comm platforms, you've definitely been given this requirement. Naturally, your next question is likely "so what counts as complex CRM functionality"? Complex CRM functionality includes any/all of the following:

  1. Triggering emails depending on a users' in-app actions
  2. Transitioning a user through a sales funnel
  3. Up and cross sells

Our argument (and the point of this post) is the following: CRM logic belongs in a dedicated CRM. In other words, the application should be as clean of CRM behavior as possible, acting strictly as a bridge to the CRM and not as a CRM itself.


In software, there are some common utterances thrown around the office fast-and-loose like. "DRY", "KISS", and "Don't reinvent the wheel" are scribbled on PR reviews and commit comments, wrapping in their kitschiness a really powerful frame-of-mind: Don't spend time on solved problems. It's really easy remembering this lesson for the "hard" problems (example: you're not building out a homegrown version of nltk or matplotlib), but too often we forget the same lesson is applicable to "easy" problems.

While it's easy to underestimate the functionality of a CRM, we'd be wise not to. Questions of deliverability, 3rd party email and analytics integrations, multi-channel outreach, and user story building are all basic features of a dedicated CRM. In other words, while it's tempting to sprinkle an email here and there at those key junctions in code, that really glosses over some key problems. How are we tracking communications with users over time? Are we empowering non-technical team members with capabilities to connect with customers?


To illustrate the point concretely, let's look at a sample Django application representing a storefront for users to purchase cars.

## models.py
from django.db import models
from django.contrib.auth.models import User as AuthUser

class Customer(models.Model):
    user = models.OneToOneField(AuthUser, related_name='customer')
 	
    def purchase_car(self, car, payment_info=None):
    	""" purchase a new car 
        :param car: `Car`
        :param payment_info: `dict`
        :return: `Purchase`
        """
        # cut some corners, don't check if there's already a purchase in progress for the given car etc.
        purchase = Purchase.objects.create(customer=self, car=car)
        
        if purchase.charge(**payment_info):
            purchase.is_complete = True
            purchase.save()
        
        return purchase


class Car(models.Model):
    brand = models.CharField(max_length=127)
    color = models.CharField(max_length=63)
    price = models.DecimalField(max_digits=12, decimal_places=2)
    

class Purchase(models.Model):
    customer = models.ForeignKey(Customer, related_name='purchases')
    car = models.ForeignKey(Car, related_name='purchases')
    
    is_complete = models.BooleanField(default=False)
    created_dt = models.DateTimeField(auto_now_add=True)
    
    def charge(self, amount=None, card_number=None):
    	""" dummy method to charge the given card information the given amount """
        pass

Simple enough, yeah? Now, let's extend our example with a few extra requirements.

As is reasonable, we'll probably want to send an email to new Customers welcoming them to our amazing product when they sign up. Additionally, we'd like to hook into a few user journey events and send an SMS when they have a purchase in progress which isn't complete. Finally, after the user does complete their purchase, we want to send them an email thanking them (and shamelessly soliciting a social share request).

## models.py
from django.db import models
from django.contrib.auth.models import User as AuthUser
# NOT A REAL LIB
from comms.catalog import AbandonedPurchase, PurchaseComplete

class Customer(models.Model):
    user = models.OneToOneField(AuthUser, related_name='customer')
 	
    def purchase_car(self, car, payment_info=None):
    	""" purchase a new car 
        :param car: `Car`
        :param payment_info: `dict`
        :return: `Purchase`
        """
        # cut some corners, don't check if there's already a purchase in progress for the given car etc.
        purchase = Purchase.objects.create(customer=self, car=car)
        
        if purchase.charge(**payment_info):
            purchase.is_complete = True
            purchase.save()
            
            emailer = PurchaseComplete(medium='email', context={'purchase': purchase})
            emailer.send()
            
            return purchase
            
        sender = AbandonedPurchase(medium='sms', context={'purchase': purchase})
        sender.send()
        
        return purchase
 
 
class Car(models.Model):
    brand = models.CharField(max_length=127)
    color = models.CharField(max_length=63)
    price = models.DecimalField(max_digits=12, decimal_places=2)
    

class Purchase(models.Model):
    customer = models.ForeignKey(Customer, related_name='purchases')
    car = models.ForeignKey(Car, related_name='purchases')
    
    is_complete = models.BooleanField(default=False)
    created_dt = models.DateTimeField(auto_now_add=True)
    
    def charge(self, amount=None, card_number=None):
    	""" dummy method to charge the given card information the given amount """
        pass
 
 ## signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from cars import models
from comms.catalog import Welcome

@receiver(post_save, sender=models.Customer)
def send_customer_welcome_email(instance=None, created=None, **kwargs):
    if created:
        emailer = Welcome(medium='email', context={'user': instance})
        emailer.send()

A famous philosopher once said, "a journey of a thousand emails begins with a single method", or something like that.

So this almost does what we want, however you'll notice on line 27 we have this:

 ## STUFFFFF...
    	sender = AbandonedPurchase(medium='sms', context={'purchase': purchase})
        sender.send()        
 ## OTHER STUFFFF...

In a real world scenario, we wouldn't want to hit our abandoned checkout with an SMS immediately after their attempt. They haven't had time to realize what type of "space for the family", "best-in-class safety features", and "great deal" they're missing out on! Really, we want to set a delay on this, something like 30 minutes. Which leaves us two options:

  1. The wonderful world of async task processing (so, Celery)
  2. A basic cron integration (something like Django Cron)

For the sake of simplicity, we'll use (2)

## settings.py
#...NORMAL SETTINGS STUFF UP HERE
INSTALLED_APPS = (
  ...
  'django_cron',
  ...
)

CRON_CLASSES = [
    "kars.cron.SendAbandonedPurchaseMessages",
    # ...
]
#...NORMAL SETTINGS STUFF DOWN HERE


## cron.py
from django_cron import CronJobBase, Schedule
from comms.models import ScheduledAbandonedPurchase
from comms.catalog import AbandonedPurchase

class SendAbandonedPurchaseMessages(CronJobBase):
    RUN_EVERY_MINS = 5 # every 5 minutes

    schedule = Schedule(run_every_mins=RUN_EVERY_MINS)
    code = 'kars.send_abandoned_purchase_messages'

    def do(self):
    	scheduled = ScheduledAbandonedPurchase.past_due()
    	scheduled = scheduled.filter(time_sent__isnull=True)
        
        for s in scheduled:
            sender = AbandonedPurchase(medium='sms', context={'purchase': s.purchase})
            sender.send()


## models.py
from django.db import models
from django.contrib.auth.models import User as AuthUser
# NOTICE: that now this is a model
from comms.models import ScheduledAbandonedPurchase
from comms.catalog import PurchaseComplete

class Customer(models.Model):
    user = models.OneToOneField(AuthUser, related_name='customer')
 	
    def purchase_car(self, car, payment_info=None):
    	""" purchase a new car 
        :param car: `Car`
        :param payment_info: `dict`
        :return: `Purchase`
        """
        # cut some corners, don't check if there's already a purchase in progress for the given car etc.
        purchase = Purchase.objects.create(customer=self, car=car)
        
        if purchase.charge(**payment_info):
            purchase.is_complete = True
            purchase.save()
            
            emailer = PurchaseComplete(medium='email', context={'purchase': purchase})
            emailer.send()
            
            return purchase
            
        ScheduledAbandonedPurchase.objects.create(purchase=purchase)
        
        return purchase
 
 
class Car(models.Model):
    brand = models.CharField(max_length=127)
    color = models.CharField(max_length=63)
    price = models.DecimalField(max_digits=12, decimal_places=2)
    

class Purchase(models.Model):
    customer = models.ForeignKey(Customer, related_name='purchases')
    car = models.ForeignKey(Car, related_name='purchases')
    
    is_complete = models.BooleanField(default=False)
    created_dt = models.DateTimeField(auto_now_add=True)
    
    def charge(self, amount=None, card_number=None):
    	""" dummy method to charge the given card information the given amount """
        pass
 
 ## signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from cars import models
from comms.catalog import Welcome

@receiver(post_save, sender=models.Customer)
def send_customer_welcome_email(instance=None, created=None, **kwargs):
    if created:
        emailer = Welcome(medium='email', context={'user': instance})
        emailer.send()

Oh, and we'll also need to make sure that whatever machine is running our cron jobs has it's crontab appropriately set like so:

# crontab
*/5 * * * * source /home/ubuntu/.bashrc && source /home/ubuntu/work/kars/bin/activate && python /home/ubuntu/work/kars/src/manage.py runcrons > /home/ubuntu/cronjob.log

We're now at a point of semi-realistic CRM functionality. But at what cost? We've been forced to create new models specifically for tracking the state of message sending, implement the actual messaging layer (though that implementation has been stubbed out of our example app), bloat the core domain logic, and implement an out-of-band processing solution to handle async tasks. In short, we've implemented the functionality provided natively by a CRM vendor.


So, what should we do instead? Let's start by looking at some of the CRM vendors out there and seeing what sort of capabilities and integrations they have. For the sake of my sanity and yours, we won't even mention that most odious of solutions (hint: it rhymes with trailhorse).

Insightly - Like the other CRM's in this list, provides all the basic functionality you would expect out of a CRM (integrations, lead tracking, etc.). One big selling point for these guys is their straightforward and well thought out REST API.

Zoho - Zoho captures a nice medium ground between Salesforce and something like Insightly with comprehensive integrations, multi-channel workflows, and more.

There are some additional ones worth looking into if you're interested (Close.io is quite good), but for now we'll just forge ahead with what we've mentioned above. Take a deep breath, we're about to refactor our application to off-load CRM functionality to Insightly.


We'll be stubbing out most of the implementation details of the bridge, but you should be able to see the gist of what we're going for. Lucky for us, we can go back to our simplified pre-cron system for this.

## models.py
from django.db import models
from django.contrib.auth.models import User as AuthUser
# NOT A REAL LIB
from crm import insightly
import datetime

class Customer(models.Model):
    user = models.OneToOneField(AuthUser, related_name='customer')
    # the ID of this customer in the CRM
    crm_id = models.CharField(max_length=255)
 	
    def purchase_car(self, car, payment_info=None):
    	""" purchase a new car 
        :param car: `Car`
        :param payment_info: `dict`
        :return: `Purchase`
        """
        # cut some corners, don't check if there's already a purchase in progress for the given car etc.
        purchase = Purchase.objects.create(customer=self, car=car)
        
        if purchase.charge(**payment_info):
            purchase.is_complete = True
            purchase.save()                                   
           
        # opportunities represent sales (or potential sales) to Contacts
        insightly.add_opportunity(
            self.crm_id,
            amount=purchase.total,
            date=datetime.datetime.now(),
            complete=purchase.is_complete
        )
        
        return purchase
 
 
class Car(models.Model):
    brand = models.CharField(max_length=127)
    color = models.CharField(max_length=63)
    price = models.DecimalField(max_digits=12, decimal_places=2)
    

class Purchase(models.Model):
    customer = models.ForeignKey(Customer, related_name='purchases')
    car = models.ForeignKey(Car, related_name='purchases')
    
    is_complete = models.BooleanField(default=False)
    created_dt = models.DateTimeField(auto_now_add=True)
    
    def charge(self, amount=None, card_number=None):
    	""" dummy method to charge the given card information the given amount """
        pass
 
 
 ## signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from cars import models
from crm import insightly

@receiver(pre_save, sender=models.Customer)
def send_contact_to_crm(instance=None, **kwargs):
    # instance.id will be None on first save
    if instance.id is None:
        external_contact = insightly.add_contact(
            email=instance.user.email,
            first_name=instance.user.first_name,
            last_name=instance.user.last_name
        )
        
        instance.crm_id = external_contact['id']        
       
       
 @receiver(post_delete, sender=models.Customer)
 def remove_contact_from_crm(instance=None, **kwargs):
    if instance.crm_id:
    	# you might not even want to do this, since keeping the user in the CRM even
        # after removal from our app might have benefits (e.g. re-engagement, surveying, etc)   
        insightly.remove_contact(instance.crm_id)

Let's review some of the changes we've made. To start, you'll notice that our comms library has thankfully disappeared. Along with it, we've managed to throw out hooks to send emails on purchase, abandoned checkout, and user creation. Instead we update the CRM with user data when updates are available and farm out the details of user communication to the CRM. One important thing to note here is that while our example uses Insightly as a CRM backend, swapping in a different provider is seamless since all CRM's have a strikingly similar view of the world (and, subsequently, data model).


Now that you've seen a basic blueprint for transitioning from in-app to vendor-backed CRM functionality, I hope you agree with the argument for externalized CRM services. If you do, please do check out a project we've been working on for seamlessly integrating your Django project with 3rd party CRM vendors, django-crmify. We'd love any and all community support to add more CRM backends, additional functionality (like extra attribute syncing, action / task updating, Py3 async support, and testing), and just generally play around with the library!

In honor of reaching the end of this post, I present you with a wild pug. Enjoy!

tikki the pug