├── tests.py ├── bin ├── __init__.py └── process.py ├── __init__.py ├── views.py ├── hooks.py ├── README.md └── models.py /tests.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bin/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | from webhooks.hooks import webhooks -------------------------------------------------------------------------------- /bin/process.py: -------------------------------------------------------------------------------- 1 | """ 2 | A script which delivers all unprocessed webhooks. 3 | 4 | This is intended to be run as a cron job. Add the following to your crontab: 5 | 6 | DJANGO_SETTINGS_MODULE=yoursite.settings 7 | */15 * * * * python /path/to/webhooks/bin/process.py 8 | 9 | """ 10 | from webhooks import webhooks 11 | webhooks.process() -------------------------------------------------------------------------------- /views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | 3 | VERIFIED = "VERIFIED" 4 | INVALID = "INVALID" 5 | 6 | # def verify(request): 7 | # """ 8 | # Returns a HTTP/200 OKAY if this we sent this message. 9 | # 10 | # """ 11 | # if request.method == "POST" and verify(request.raw_post_data): 12 | # return HttpResponse(VERIFIED) 13 | # else: 14 | # return HttpResponse(INVALID) 15 | 16 | 17 | def listener(request): 18 | print request.get_full_path() 19 | print request.raw_post_data 20 | print request.POST 21 | -------------------------------------------------------------------------------- /hooks.py: -------------------------------------------------------------------------------- 1 | from django.db.models.base import ModelBase 2 | 3 | from webhooks.models import Message, MessageQueue 4 | 5 | 6 | class WebHookRegistery(object): 7 | """ 8 | All Hooks must register here! 9 | 10 | """ 11 | def __init__(self): 12 | self.registry = {} 13 | 14 | def register(self, model_or_iterable, fields, signal=None, serializer=None, 15 | retries=1, synchronous=False): 16 | """ 17 | Default WebHook uses the Django JSON serializer with the post_save signal. 18 | `model_or_iterable` is a model or iterable of models to register WebHooks for. 19 | `fields` is a list of model fields to be serialized. 20 | `signal` is the signal to register on the model. 21 | `serializer` is the serializer used to create the message. 22 | `retries` is the number of times to attempt to deliver a message to a Listener. 23 | `synchronous` where hooks should be processed as they happen or as a cron job. 24 | 25 | """ 26 | if isinstance(model_or_iterable, ModelBase): 27 | model_or_iterable = [model_or_iterable] 28 | 29 | if signal is None: 30 | from django.db.models.signals import post_save 31 | signal = post_save 32 | 33 | if serializer is None: 34 | from django.core import serializers 35 | JSONSerializer = serializers.get_serializer("json") 36 | serializer = JSONSerializer() 37 | 38 | webhook = WebHook(fields=fields, signal=signal, serializer=serializer, 39 | retries=retries, synchronous=synchronous) 40 | 41 | for model in model_or_iterable: 42 | self.registry[model] = webhook 43 | webhook.connect(model) 44 | 45 | def process(self): 46 | """ 47 | Deliver all messages. 48 | 49 | """ 50 | # Send all new Messages. 51 | qs = Message.objects.filter(processed=False).select_related(depth=1) 52 | for m in qs: 53 | m.process(self.registry[m.obj._default_manager.model]) 54 | 55 | # Retry all failed MessageQueue instances. 56 | for webhook in self.registry.values(): 57 | qs = MessageQueue.objects.filter(processed=False, attempts__lt=webhook.retries) 58 | for mq in qs: 59 | mq.process() 60 | 61 | # Everything that hasn't been processed and has too many retries is done. 62 | qs = MessageQueue.objects.filter(processed=False, attempts__gte=webhook.retries).update(processed=True) 63 | 64 | 65 | class WebHook(object): 66 | def __init__(self, fields=None, signal=None, serializer=None, retries=1, 67 | synchronous=False): 68 | self.fields = fields 69 | self.signal = signal 70 | self.serializer = serializer 71 | self.retries = retries 72 | self.synchronous = synchronous 73 | 74 | def connect(self, model): 75 | self.signal.connect(self.send, sender=model) 76 | 77 | def send(self, sender, **kwargs): 78 | """ 79 | If synchronous then send the hook message. Otherwise set it in the queue for later. 80 | 81 | """ 82 | m = Message.objects.create(obj=kwargs['instance']) 83 | if self.synchronous: 84 | m.process() 85 | 86 | 87 | webhooks = WebHookRegistery() -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Django WebHooks 2 | =============== 3 | 4 | About 5 | ----- 6 | 7 | > Web Hooks is an open initiative to standardize event notifications between web services by subscribing to URLs. 8 | 9 | Django WebHooks makes it easy to integrate WebHooks into your Django Project. 10 | 11 | 12 | Using Django Webhooks 13 | --------------------- 14 | 15 | 1. Download the code from GitHub: 16 | 17 | git clone git://github.com/johnboxall/django-webhooks.git webhooks 18 | 19 | 1. Edit `settings.py` and add `webhooks` to your `INSTALLED_APPS`: 20 | 21 | # settings.py 22 | ... 23 | INSTALLED_APPS = (... 'webhooks', ...) 24 | 25 | 1. Register webhooks for models - `admin.py` is a good place: 26 | 27 | # admin.py 28 | 29 | from webhooks import webhooks 30 | 31 | webhooks.register(MyModel, ["fields", "to", "serialize"]) 32 | 33 | 1. Create Listeners for the webhook. For example to create a Listener that will be messaged whenever a `User` instance with `username="john"` is saved: 34 | 35 | from django.contrib.auth.models import User 36 | from django.contrib.contenttypes.models import ContentType 37 | from webhooks.models import Listener 38 | 39 | user_type = ContentType.objects.get(app_label="auth", model="user") 40 | john = User.objects.get(username="john") 41 | 42 | Listener.create(obj_type=user_type, 43 | obj_property="username", 44 | obj_value="john", 45 | url="http://where.john/is/listening/", 46 | owner=john) 47 | 48 | 1. Profit. 49 | 50 | 51 | Creating a Webhook Endpoint 52 | --------------------------- 53 | 54 | 1. **Django:** 55 | 56 | # views.py 57 | import simplejson 58 | 59 | def listener(request): 60 | json = request.raw_post_data 61 | webhook = simplejson.loads(json) 62 | 63 | 64 | 1. **PHP:** 65 | 66 | 76 | 77 | 78 | Details 79 | ------- 80 | 81 | 1. HTTP POST request / raw_post_data 82 | 1. Creating a listener ... 83 | 1. ... 84 | 85 | 86 | ToDo 87 | ---- 88 | 89 | 1. Code in `webhooks.models.Message` should be moved into `webhooks.helpers` so it can be subclassed / overridden more easily. 90 | 1. Add HMAC authorization headers to all Webhooks messages ([http://code.google.com/p/support/wiki/PostCommitWebHooks](see Google Code implementation) 91 | 1. Add verify view for (think PayPal IPN) 92 | 93 | 94 | Resources 95 | --------- 96 | 97 | * http://blog.webhooks.org/ 98 | * http://blogrium.com/2006/12/27/automator-for-the-web/ 99 | * http://blogrium.com/2006/11/27/lets-make-seeking-bliss-easier/ 100 | * http://blogrium.com/?p=70 101 | * http://www.slideshare.net/progrium/web-hooks Web-Hooks by blogrium.com / superhappydevhouse 102 | * http://groups.google.com/group/webhooks/ webhooks google group 103 | * http://code.google.com/p/support/wiki/PostCommitWebHooks 104 | * http://www.slideshare.net/progrium/web-hooks-and-the-programmable-world-of-tomorrow-presentation 105 | * http://github.com/guides/post-receive-hooks -------------------------------------------------------------------------------- /models.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import urllib 3 | import urlparse 4 | import datetime 5 | 6 | from django.db import models 7 | from django.contrib.contenttypes import generic 8 | from django.contrib.contenttypes.models import ContentType 9 | 10 | 11 | TIMEOUT = 3 12 | 13 | class CreatedUpdatedModel(models.Model): 14 | created_at = models.DateTimeField(auto_now_add=True) 15 | updated_at = models.DateTimeField(auto_now=True) 16 | 17 | class Meta: 18 | abstract = True 19 | 20 | 21 | class Message(CreatedUpdatedModel): 22 | """ 23 | A Message is sent when a WebHook is activated. 24 | 25 | """ 26 | obj_type = models.ForeignKey('contenttypes.ContentType', help_text="Content type of the object of the message.") 27 | obj_id = models.PositiveIntegerField(help_text="ID of the object of the message.") 28 | obj = generic.GenericForeignKey("obj_type", "obj_id") 29 | payload = models.TextField(blank=True, help_text="Content of the message.") 30 | digest = models.CharField(blank=True, max_length=256, help_text="Hexdigest of the message's payload. Used to verify postbacks.") 31 | processed = models.BooleanField(default=False) 32 | 33 | def __unicode__(self): 34 | return unicode(self.obj) 35 | 36 | def process(self, hook): 37 | """ 38 | If anyone is listening to this message then deliver it to them. Otherwise delete it. 39 | 40 | """ 41 | if self.has_listeners(): 42 | self.serialize(hook.fields, hook.serializer) 43 | self.deliver() 44 | self.processed = True 45 | self.save() 46 | else: 47 | self.delete() 48 | 49 | def deliver(self): 50 | """ 51 | Deliver this message to each Listener by creating a MessageQueue instance and delivering it. 52 | 53 | """ 54 | for listener in self.listeners: 55 | MessageQueue.objects.create(message=self, listener=listener).process() 56 | 57 | def serialize(self, fields, serializer, **kwargs): 58 | """ 59 | Serialize obj using fields and serializer. 60 | 61 | """ 62 | # ### TODO: Could use a hasher to calculate a message hash here. 63 | self.payload = serializer.serialize([self.obj], fields=fields, **kwargs) 64 | 65 | @property 66 | def listeners(self): 67 | """ 68 | Return a QuerySet of Listeners for this message. 69 | 70 | Strategy: 71 | 1. Look at all the Listeners looking at this model and see what properties they are watching. 72 | 2. Look for all Listeners which have property values equal to the 73 | 74 | """ 75 | if not hasattr(self, '_listeners_cache'): 76 | props = Listener.objects.filter(obj_type=self.obj_type).values_list("obj_property", flat=True).distinct() 77 | if len(props): 78 | filter_listeners_query = None 79 | for property_name in props: 80 | query_property_value = {"obj_value": getattr(self.obj, property_name)} 81 | query_property_name = {"obj_property": property_name} 82 | query = models.Q(**query_property_value) & models.Q(**query_property_name) 83 | if filter_listeners_query is None: 84 | filter_listeners_query = query 85 | else: 86 | filter_listeners_query |= query 87 | self._listeners_cache = Listener.objects.filter(filter_listeners_query) 88 | else: 89 | self._listeners_cache = self.obj._default_manager.none() 90 | return self._listeners_cache 91 | 92 | def has_listeners(self): 93 | """ 94 | Return True if this Message has any Listeners. 95 | 96 | """ 97 | return len(self.listeners) > 0 98 | 99 | 100 | class Listener(CreatedUpdatedModel): 101 | """ 102 | A Listener is waiting waiting at a url for a hook from a model with certain properties. 103 | 104 | """ 105 | obj_type = models.ForeignKey('contenttypes.ContentType', 106 | help_text="The type of object I'm listening to.", 107 | related_name="listening_for") 108 | obj_property = models.CharField(max_length=32, blank=True, help_text="The property I'm listening for.") 109 | obj_value = models.CharField(max_length=32, blank=True, help_text="The value of the property I'm listening for.") 110 | url = models.URLField(verify_exists=False, help_text="The URL I'm listening at.") 111 | owner_type = models.ForeignKey('contenttypes.ContentType', related_name="listening_to") 112 | owner_id = models.PositiveIntegerField() 113 | owner = generic.GenericForeignKey('owner_type', 'owner_id') 114 | 115 | def __unicode__(self): 116 | return self.url 117 | 118 | 119 | class MessageQueue(CreatedUpdatedModel): 120 | """ 121 | A instance of a hook message. 122 | 123 | """ 124 | message = models.ForeignKey('webhooks.Message', help_text="What message is being sent.") 125 | listener = models.ForeignKey('webhooks.Listener', help_text="Where this message is being sent.") 126 | processed = models.BooleanField(default=False, help_text="True if this message was successfully received.") 127 | attempts = models.IntegerField(default=0, help_text="Number of attempts to deliver this message.") 128 | failed_at = models.DateTimeField(null=True, blank=True, help_text="Last time this message failed.") 129 | 130 | class Meta: 131 | verbose_name = "message queue" 132 | verbose_name_plural = "message queue" 133 | 134 | def __unicode__(self): 135 | return unicode(self.listener) 136 | 137 | def process(self): 138 | self.attempts += 1 139 | if self.deliver(fail_silently=True): 140 | self.processed = True 141 | else: 142 | self.failed_at = datetime.datetime.now() 143 | self.save() 144 | 145 | def deliver(self, fail_silently=False): 146 | """ 147 | Returns True if the message was successfully delivered. 148 | 149 | """ 150 | # ### Look at the response - if not 200 then failed. 151 | import urllib2 152 | original_timeout = socket.getdefaulttimeout() 153 | socket.setdefaulttimeout(TIMEOUT) 154 | try: 155 | urllib2.urlopen(self.listener.url, self.message.payload) 156 | except urllib2.URLError: 157 | print 'except' 158 | if fail_silently: 159 | return False 160 | else: 161 | raise 162 | else: 163 | return True 164 | finally: 165 | socket.setdefaulttimeout(original_timeout) 166 | 167 | 168 | 169 | 170 | # def _get_hasher(hasher): 171 | # if hasher is None: 172 | # from django.utils import hashcompat 173 | # return hashcompat.sha_constructor 174 | # else: 175 | # return hasher --------------------------------------------------------------------------------