├── .gitignore ├── .travis.yml ├── AUTHORS.md ├── LICENSE.md ├── README.md ├── devrequirements.txt ├── devrequirements_1.10.txt ├── devrequirements_1.11.txt ├── devrequirements_1.5.txt ├── devrequirements_1.6.txt ├── devrequirements_1.7.txt ├── devrequirements_1.8.txt ├── devrequirements_1.9.txt ├── devrequirements_2.0.txt ├── rest_hooks ├── __init__.py ├── admin.py ├── client.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_swappable_hook_model.py │ └── __init__.py ├── models.py ├── signals.py ├── south_migrations │ ├── 0001_initial.py │ └── __init__.py ├── tasks.py ├── tests.py └── utils.py ├── runtests.py ├── setup.cfg └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/pycharm,python 2 | 3 | ### PyCharm ### 4 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 5 | 6 | *.iml 7 | 8 | ## Directory-based project format: 9 | .idea/ 10 | # if you remove the above rule, at least ignore the following: 11 | 12 | # User-specific stuff: 13 | # .idea/workspace.xml 14 | # .idea/tasks.xml 15 | # .idea/dictionaries 16 | # .idea/shelf 17 | 18 | # Sensitive or high-churn files: 19 | # .idea/dataSources.ids 20 | # .idea/dataSources.xml 21 | # .idea/sqlDataSources.xml 22 | # .idea/dynamic.xml 23 | # .idea/uiDesigner.xml 24 | 25 | # Gradle: 26 | # .idea/gradle.xml 27 | # .idea/libraries 28 | 29 | # Mongo Explorer plugin: 30 | # .idea/mongoSettings.xml 31 | 32 | ## File-based project format: 33 | *.ipr 34 | *.iws 35 | 36 | ## Plugin-specific files: 37 | 38 | # IntelliJ 39 | /out/ 40 | 41 | # mpeltonen/sbt-idea plugin 42 | .idea_modules/ 43 | 44 | # JIRA plugin 45 | atlassian-ide-plugin.xml 46 | 47 | # Crashlytics plugin (for Android Studio and IntelliJ) 48 | com_crashlytics_export_strings.xml 49 | crashlytics.properties 50 | crashlytics-build.properties 51 | fabric.properties 52 | 53 | 54 | ### Python ### 55 | # Byte-compiled / optimized / DLL files 56 | __pycache__/ 57 | *.py[cod] 58 | *$py.class 59 | 60 | # C extensions 61 | *.so 62 | 63 | # Distribution / packaging 64 | .Python 65 | env/ 66 | build/ 67 | develop-eggs/ 68 | dist/ 69 | downloads/ 70 | eggs/ 71 | .eggs/ 72 | lib/ 73 | lib64/ 74 | parts/ 75 | sdist/ 76 | var/ 77 | *.egg-info/ 78 | .installed.cfg 79 | *.egg 80 | 81 | # PyInstaller 82 | # Usually these files are written by a python script from a template 83 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 84 | *.manifest 85 | *.spec 86 | 87 | # Installer logs 88 | pip-log.txt 89 | pip-delete-this-directory.txt 90 | 91 | # Unit test / coverage reports 92 | htmlcov/ 93 | .tox/ 94 | .coverage 95 | .coverage.* 96 | .cache 97 | nosetests.xml 98 | coverage.xml 99 | *,cover 100 | .hypothesis/ 101 | 102 | # Translations 103 | *.mo 104 | *.pot 105 | 106 | # Django stuff: 107 | *.log 108 | 109 | # Sphinx documentation 110 | docs/_build/ 111 | 112 | # PyBuilder 113 | target/ 114 | 115 | #SQLite 116 | *.sqlite 117 | .sass-cache 118 | 119 | #Test Coverage 120 | tests/reports/ 121 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | arch: 4 | - amd64 5 | - ppc64le 6 | 7 | python: 8 | - "2.7" 9 | - "3.4" 10 | - "3.6" 11 | - "3.8" 12 | 13 | env: 14 | - DJANGO_VERSION="1.5" 15 | - DJANGO_VERSION="1.6" 16 | - DJANGO_VERSION="1.7" 17 | - DJANGO_VERSION="1.8" 18 | - DJANGO_VERSION="1.9" 19 | - DJANGO_VERSION="1.10" 20 | - DJANGO_VERSION="1.11" 21 | - DJANGO_VERSION="2.0" 22 | 23 | install: 24 | - pip install -r devrequirements_${DJANGO_VERSION}.txt 25 | 26 | script: python runtests.py 27 | 28 | matrix: 29 | exclude: 30 | - python: "3.6" 31 | env: DJANGO_VERSION="1.5" 32 | - python: "3.6" 33 | env: DJANGO_VERSION="1.6" 34 | - python: "3.6" 35 | env: DJANGO_VERSION="1.7" 36 | - python: "2.7" 37 | env: DJANGO_VERSION="2.0" 38 | - python: "3.8" 39 | env: DJANGO_VERSION="1.5" 40 | - python: "3.8" 41 | env: DJANGO_VERSION="1.6" 42 | - python: "3.8" 43 | env: DJANGO_VERSION="1.7" 44 | - python: "3.8" 45 | env: DJANGO_VERSION="1.8" 46 | - python: "3.8" 47 | env: DJANGO_VERSION="1.9" 48 | - python: "3.8" 49 | env: DJANGO_VERSION="1.10" 50 | 51 | 52 | before_deploy: 53 | - pip install wheel 54 | - python setup.py sdist bdist_wheel 55 | 56 | deploy: 57 | provider: pypi 58 | user: bryanhelmig 59 | password: 60 | secure: amY+WgU7S4RD/8S4rDz6/Gso1bucyqWWdCfG5RXHxD1mBcOIBjfiVmDkbiOxODany3KS5Hmo3mvXjOlpgmhuxw2iWdG1o059pMh8PH7I2WwHTliUSGwIshFIUmAivrh1mq9qUsHfsGpPow3AaxFB7G/FnrAQjedTTGYfN5ZnI/k= 61 | distributions: "sdist bdist_wheel" 62 | on: 63 | tags: true 64 | repo: zapier/django-rest-hooks 65 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | Django REST Hooks is written and maintained by Zapier and 2 | various contributors: 3 | 4 | 5 | ## Development Lead 6 | 7 | - Bryan Helmig 8 | 9 | 10 | ## Patches and Suggestions 11 | 12 | - [Bryan Helmig](https://github.com/bryanhelmig) 13 | - [Arnaud Limbourg](https://github.com/arnaudlimbourg) 14 | - [tdruez](https://github.com/tdruez) 15 | - [Maina Nick](https://github.com/mainanick) 16 | - Jonathan Moss 17 | - [Erik Wickstrom](https://github.com/erikcw) 18 | - [Yaroslav Klyuyev](https://github.com/imposeren) 19 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | ## ISC License 2 | 3 | Copyright (c) 2016 Zapier Inc. 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Travis CI Build](https://img.shields.io/travis/zapier/django-rest-hooks/master.svg)](https://travis-ci.org/zapier/django-rest-hooks) 2 | [![PyPI Download](https://img.shields.io/pypi/v/django-rest-hooks.svg)](https://pypi.python.org/pypi/django-rest-hooks) 3 | [![PyPI Status](https://img.shields.io/pypi/status/django-rest-hooks.svg)](https://pypi.python.org/pypi/django-rest-hooks) 4 | 5 | ## What are Django REST Hooks? 6 | 7 | 8 | REST Hooks are fancier versions of webhooks. Traditional webhooks are usually 9 | managed manually by the user, but REST Hooks are not! They encourage RESTful 10 | access to the hooks (or subscriptions) themselves. Add one, two or 15 hooks for 11 | any combination of event and URLs, then get notificatied in real-time by our 12 | bundled threaded callback mechanism. 13 | 14 | The best part is: by reusing Django's great signals framework, this library is 15 | dead simple. Here's how to get started: 16 | 17 | 1. Add `'rest_hooks'` to installed apps in settings.py. 18 | 2. Define your `HOOK_EVENTS` in settings.py. 19 | 3. Start sending hooks! 20 | 21 | Using our **built-in actions**, zero work is required to support *any* basic `created`, 22 | `updated`, and `deleted` actions across any Django model. We also allow for 23 | **custom actions** (IE: beyond **C**R**UD**) to be simply defined and triggered 24 | for any model, as well as truly custom events that let you send arbitrary 25 | payloads. 26 | 27 | By default, this library will just POST Django's JSON serialization of a model, 28 | but you can alternatively provide a `serialize_hook` method to customize payloads. 29 | 30 | *Please note:* this package does not implement any UI/API code, it only 31 | provides a handy framework or reference implementation for which to build upon. 32 | If you want to make a Django form or API resource, you'll need to do that yourself 33 | (though we've provided some example bits of code below). 34 | 35 | 36 | ### Changelog 37 | 38 | #### Version 1.6.0: 39 | 40 | Improvements: 41 | 42 | * Default handler of `raw_hook_event` uses the same logic as other handlers 43 | (see "Backwards incompatible changes" for details). 44 | 45 | * Lookup of event_name by model+action_name now has a complexity of `O(1)` 46 | instead of `O(len(settings.HOOK_EVENTS))` 47 | 48 | * `HOOK_CUSTOM_MODEL` is now similar to `AUTH_USER_MODEL`: must be of the form 49 | `app_label.model_name` (for django 1.7+). If old value is of the form 50 | `app_label.models.model_name` then it's automatically adapted. 51 | 52 | * `rest_hooks.models.Hook` is now really "swappable", so table creation is 53 | skipped if you have different `settings.HOOK_CUSTOM_MODEL` 54 | 55 | * `rest_hooks.models.AbstractHook.deliver_hook` now accepts a callable as 56 | `payload_override` argument (must accept 2 arguments: hook, instance). This 57 | was added to support old behavior of `raw_custom_event`. 58 | 59 | Fixes: 60 | 61 | * HookAdmin.form now honors `settings.HOOK_CUSTOM_MODEL` 62 | 63 | * event_name determined from action+model is now consistent between runs (see 64 | "Backwards incompatible changes") 65 | 66 | Backwards incompatible changes: 67 | 68 | * Dropped support for django 1.4 69 | * Custom `HOOK_FINDER`-s should accept and handle new argument `payload_override`. 70 | Built-in finder `rest_hooks.utls.find_and_fire_hook` already does this. 71 | * If several event names in `settings.HOOK_EVENTS` share the same 72 | `'app_label.model.action'` (including `'app_label.model.action+'`) then 73 | `django.core.exceptions.ImproperlyConfigured` is raised 74 | * Receiver of `raw_hook_event` now uses the same logic as receivers of other 75 | signals: checks event_name against settings.HOOK_EVENTS, verifies model (if 76 | instance is passed), uses `HOOK_FINDER`. Old behaviour can be achieved by 77 | using `trust_event_name=True`, or `instance=None` to fire a signal. 78 | * If you have `settings.HOOK_CUSTOM_MODEL` of the form different than 79 | `app_label.models.model_name` or `app_label.model_name`, then it must 80 | be changed to `app_label.model_name`. 81 | 82 | 83 | ### Development 84 | 85 | Running the tests for Django REST Hooks is very easy, just: 86 | 87 | ``` 88 | git clone https://github.com/zapier/django-rest-hooks && cd django-rest-hooks 89 | ``` 90 | 91 | Next, you'll want to make a virtual environment (we recommend using virtualenvwrapper 92 | but you could skip this we suppose) and then install dependencies: 93 | 94 | ``` 95 | mkvirtualenv django-rest-hooks 96 | pip install -r devrequirements.txt 97 | ``` 98 | 99 | Now you can run the tests! 100 | 101 | ``` 102 | python runtests.py 103 | ``` 104 | 105 | ### Requirements 106 | 107 | * Python 2 or 3 (tested on 2.7, 3.3, 3.4, 3.6) 108 | * Django 1.5+ (tested on 1.5, 1.6, 1.7, 1.8, 1.9, 1.10, 1.11, 2.0) 109 | 110 | ### Installing & Configuring 111 | 112 | We recommend pip to install Django REST Hooks: 113 | 114 | ``` 115 | pip install django-rest-hooks 116 | ``` 117 | 118 | Next, you'll need to add `rest_hooks` to `INSTALLED_APPS` and configure 119 | your `HOOK_EVENTS` setting: 120 | 121 | ```python 122 | ### settings.py ### 123 | 124 | INSTALLED_APPS = ( 125 | # other apps here... 126 | 'rest_hooks', 127 | ) 128 | 129 | HOOK_EVENTS = { 130 | # 'any.event.name': 'App.Model.Action' (created/updated/deleted) 131 | 'book.added': 'bookstore.Book.created', 132 | 'book.changed': 'bookstore.Book.updated+', 133 | 'book.removed': 'bookstore.Book.deleted', 134 | # and custom events, no extra meta data needed 135 | 'book.read': 'bookstore.Book.read', 136 | 'user.logged_in': None 137 | } 138 | 139 | ### bookstore/models.py ### 140 | 141 | class Book(models.Model): 142 | # NOTE: it is important to have a user property 143 | # as we use it to help find and trigger each Hook 144 | # which is specific to users. If you want a Hook to 145 | # be triggered for all users, add '+' to built-in Hooks 146 | # or pass user_override=False for custom_hook events 147 | user = models.ForeignKey('auth.User', on_delete=models.CASCADE) 148 | # maybe user is off a related object, so try... 149 | # user = property(lambda self: self.intermediary.user) 150 | 151 | title = models.CharField(max_length=128) 152 | pages = models.PositiveIntegerField() 153 | fiction = models.BooleanField() 154 | 155 | # ... other fields here ... 156 | 157 | def serialize_hook(self, hook): 158 | # optional, there are serialization defaults 159 | # we recommend always sending the Hook 160 | # metadata along for the ride as well 161 | return { 162 | 'hook': hook.dict(), 163 | 'data': { 164 | 'id': self.id, 165 | 'title': self.title, 166 | 'pages': self.pages, 167 | 'fiction': self.fiction, 168 | # ... other fields here ... 169 | } 170 | } 171 | 172 | def mark_as_read(self): 173 | # models can also have custom defined events 174 | from rest_hooks.signals import hook_event 175 | hook_event.send( 176 | sender=self.__class__, 177 | action='read', 178 | instance=self # the Book object 179 | ) 180 | ``` 181 | 182 | For the simplest experience, you'll just piggyback off the standard ORM which will 183 | handle the basic `created`, `updated` and `deleted` signals & events: 184 | 185 | ```python 186 | >>> from django.contrib.auth.models import User 187 | >>> from rest_hooks.models import Hook 188 | >>> jrrtolkien = User.objects.create(username='jrrtolkien') 189 | >>> hook = Hook(user=jrrtolkien, 190 | event='book.added', 191 | target='http://example.com/target.php') 192 | >>> hook.save() # creates the hook and stores it for later... 193 | >>> from bookstore.models import Book 194 | >>> book = Book(user=jrrtolkien, 195 | title='The Two Towers', 196 | pages=327, 197 | fiction=True) 198 | >>> book.save() # fires off 'bookstore.Book.created' hook automatically 199 | ... 200 | ``` 201 | 202 | > NOTE: If you try to register an invalid event hook (not listed on HOOK_EVENTS in settings.py) 203 | you will get a **ValidationError**. 204 | 205 | Now that the book has been created, `http://example.com/target.php` will get: 206 | 207 | ``` 208 | POST http://example.com/target.php \ 209 | -H Content-Type: application/json \ 210 | -d '{"hook": { 211 | "id": 123, 212 | "event": "book.added", 213 | "target": "http://example.com/target.php"}, 214 | "data": { 215 | "title": "The Two Towers", 216 | "pages": 327, 217 | "fiction": true}}' 218 | ``` 219 | 220 | You can continue the example, triggering two more hooks in a similar method. However, 221 | since we have no hooks set up for `'book.changed'` or `'book.removed'`, they wouldn't get 222 | triggered anyways. 223 | 224 | ```python 225 | ... 226 | >>> book.title += ': Deluxe Edition' 227 | >>> book.pages = 352 228 | >>> book.save() # would fire off 'bookstore.Book.updated' hook automatically 229 | >>> book.delete() # would fire off 'bookstore.Book.deleted' hook automatically 230 | ``` 231 | 232 | You can also fire custom events with an arbitrary payload: 233 | 234 | ```python 235 | from rest_hooks.signals import raw_hook_event 236 | 237 | user = User.objects.get(id=123) 238 | raw_hook_event.send( 239 | sender=None, 240 | event_name='user.logged_in', 241 | payload={ 242 | 'username': user.username, 243 | 'email': user.email, 244 | 'when': datetime.datetime.now().isoformat() 245 | }, 246 | user=user # required: used to filter Hooks 247 | ) 248 | ``` 249 | 250 | 251 | ### How does it work? 252 | 253 | Django has a stellar [signals framework](https://docs.djangoproject.com/en/dev/topics/signals/), all 254 | REST Hooks does is register to receive all `post_save` (created/updated) and `post_delete` (deleted) 255 | signals. It then filters them down by: 256 | 257 | 1. Which `App.Model.Action` actually have an event registered in `settings.HOOK_EVENTS`. 258 | 2. After it verifies that a matching event exists, it searches for matching Hooks via the ORM. 259 | 3. Any Hooks that are found for the User/event combination get sent a payload via POST. 260 | 261 | 262 | ### How would you interact with it in the real world? 263 | 264 | **Let's imagine for a second that you've plugged REST Hooks into your API**. 265 | One could definitely provide a user interface to create hooks themselves via 266 | a standard browser & HTML based CRUD interface, but the real magic is when 267 | the Hook resource is part of an API. 268 | 269 | The basic target functionality is: 270 | 271 | ```shell 272 | POST http://your-app.com/api/hooks?username=me&api_key=abcdef \ 273 | -H Content-Type: application/json \ 274 | -d '{"target": "http://example.com/target.php", 275 | "event": "book.added"}' 276 | ``` 277 | 278 | Now, whenever a Book is created (either via an ORM, a Django form, admin, etc...), 279 | `http://example.com/target.php` will get: 280 | 281 | ```shell 282 | POST http://example.com/target.php \ 283 | -H Content-Type: application/json \ 284 | -d '{"hook": { 285 | "id": 123, 286 | "event": "book.added", 287 | "target": "http://example.com/target.php"}, 288 | "data": { 289 | "title": "Structure and Interpretation of Computer Programs", 290 | "pages": 657, 291 | "fiction": false}}' 292 | ``` 293 | 294 | *It is important to note that REST Hooks will handle all of this hook 295 | callback logic for you automatically.* 296 | 297 | But you can stop it anytime you like with a simple: 298 | 299 | ``` 300 | DELETE http://your-app.com/api/hooks/123?username=me&api_key=abcdef 301 | ``` 302 | 303 | If you already have a REST API, this should be relatively straightforward, 304 | but if not, Tastypie is a great choice. 305 | 306 | Some reference [Tastypie](http://tastypieapi.org/) or [Django REST framework](http://django-rest-framework.org/): + REST Hook code is below. 307 | 308 | #### Tastypie 309 | 310 | ```python 311 | ### resources.py ### 312 | 313 | from tastypie.resources import ModelResource 314 | from tastypie.authentication import ApiKeyAuthentication 315 | from tastypie.authorization import Authorization 316 | from rest_hooks.models import Hook 317 | 318 | class HookResource(ModelResource): 319 | def obj_create(self, bundle, request=None, **kwargs): 320 | return super(HookResource, self).obj_create(bundle, 321 | request, 322 | user=request.user) 323 | 324 | def apply_authorization_limits(self, request, object_list): 325 | return object_list.filter(user=request.user) 326 | 327 | class Meta: 328 | resource_name = 'hooks' 329 | queryset = Hook.objects.all() 330 | authentication = ApiKeyAuthentication() 331 | authorization = Authorization() 332 | allowed_methods = ['get', 'post', 'delete'] 333 | fields = ['event', 'target'] 334 | 335 | ### urls.py ### 336 | 337 | from tastypie.api import Api 338 | 339 | v1_api = Api(api_name='v1') 340 | v1_api.register(HookResource()) 341 | 342 | urlpatterns = patterns('', 343 | (r'^api/', include(v1_api.urls)), 344 | ) 345 | ``` 346 | #### Django REST framework (3.+) 347 | 348 | ```python 349 | ### serializers.py ### 350 | 351 | from django.conf import settings 352 | from rest_framework import serializers, exceptions 353 | 354 | from rest_hooks.models import Hook 355 | 356 | 357 | class HookSerializer(serializers.ModelSerializer): 358 | def validate_event(self, event): 359 | if event not in settings.HOOK_EVENTS: 360 | err_msg = "Unexpected event {}".format(event) 361 | raise exceptions.ValidationError(detail=err_msg, code=400) 362 | return event 363 | 364 | class Meta: 365 | model = Hook 366 | fields = '__all__' 367 | read_only_fields = ('user',) 368 | 369 | ### views.py ### 370 | 371 | from rest_framework import viewsets 372 | 373 | from rest_hooks.models import Hook 374 | 375 | from .serializers import HookSerializer 376 | 377 | 378 | class HookViewSet(viewsets.ModelViewSet): 379 | """ 380 | Retrieve, create, update or destroy webhooks. 381 | """ 382 | queryset = Hook.objects.all() 383 | model = Hook 384 | serializer_class = HookSerializer 385 | 386 | def perform_create(self, serializer): 387 | serializer.save(user=self.request.user) 388 | 389 | ### urls.py ### 390 | 391 | from rest_framework import routers 392 | 393 | from . import views 394 | 395 | router = routers.SimpleRouter(trailing_slash=False) 396 | router.register(r'webhooks', views.HookViewSet, 'webhook') 397 | 398 | urlpatterns = router.urls 399 | ``` 400 | 401 | ### Some gotchas: 402 | 403 | Instead of doing blocking HTTP requests inside of signals, we've opted 404 | for a simple Threading pool that should handle the majority of use cases. 405 | 406 | However, if you use Celery, we'd *really* recommend using a simple task 407 | to handle this instead of threads. A quick example: 408 | 409 | ```python 410 | ### settings.py ### 411 | 412 | HOOK_DELIVERER = 'path.to.tasks.deliver_hook_wrapper' 413 | 414 | 415 | ### tasks.py ### 416 | 417 | from celery.task import Task 418 | 419 | import json 420 | import requests 421 | 422 | 423 | class DeliverHook(Task): 424 | max_retries = 5 425 | 426 | def run(self, target, payload, instance_id=None, hook_id=None, **kwargs): 427 | """ 428 | target: the url to receive the payload. 429 | payload: a python primitive data structure 430 | instance_id: a possibly None "trigger" instance ID 431 | hook_id: the ID of defining Hook object 432 | """ 433 | try: 434 | response = requests.post( 435 | url=target, 436 | data=json.dumps(payload), 437 | headers={'Content-Type': 'application/json'} 438 | ) 439 | if response.status_code >= 500: 440 | response.raise_for_status() 441 | except requests.ConnectionError: 442 | delay_in_seconds = 2 ** self.request.retries 443 | self.retry(countdown=delay_in_seconds) 444 | 445 | 446 | def deliver_hook_wrapper(target, payload, instance, hook): 447 | # instance is None if using custom event, not built-in 448 | if instance is not None: 449 | instance_id = instance.id 450 | else: 451 | instance_id = None 452 | # pass ID's not objects because using pickle for objects is a bad thing 453 | kwargs = dict(target=target, payload=payload, 454 | instance_id=instance_id, hook_id=hook.id) 455 | DeliverHook.apply_async(kwargs=kwargs) 456 | 457 | ``` 458 | 459 | We also don't handle retries or cleanup. Generally, if you get a `410` or 460 | a bunch of `4xx` or `5xx`, you should delete the Hook and let the user know. 461 | 462 | ### Extend the Hook model: 463 | 464 | The default `Hook` model fields can be extended using the `AbstractHook` model. 465 | For example, to add a `is_active` field on your hooks: 466 | 467 | ```python 468 | ### settings.py ### 469 | 470 | HOOK_CUSTOM_MODEL = 'path.to.models.CustomHook' 471 | 472 | ### models.py ### 473 | 474 | from django.db import models 475 | from rest_hooks.models import AbstractHook 476 | 477 | class CustomHook(AbstractHook): 478 | is_active = models.BooleanField(default=True) 479 | ``` 480 | 481 | The extended `CustomHook` model can be combined with a the `HOOK_FINDER` setting 482 | for advanced QuerySet filtering. 483 | 484 | ```python 485 | ### settings.py ### 486 | 487 | HOOK_FINDER = 'path.to.find_and_fire_hook' 488 | 489 | ### utils.py ### 490 | 491 | from .models import CustomHook 492 | 493 | def find_and_fire_hook(event_name, instance, **kwargs): 494 | filters = { 495 | 'event': event_name, 496 | 'is_active': True, 497 | } 498 | 499 | hooks = CustomHook.objects.filter(**filters) 500 | for hook in hooks: 501 | hook.deliver_hook(instance) 502 | ``` 503 | -------------------------------------------------------------------------------- /devrequirements.txt: -------------------------------------------------------------------------------- 1 | requests==1.2.3 2 | mock==1.0.1 3 | -------------------------------------------------------------------------------- /devrequirements_1.10.txt: -------------------------------------------------------------------------------- 1 | -r devrequirements.txt 2 | django-contrib-comments>=1.7.2 3 | Django>=1.10,<1.11 4 | -------------------------------------------------------------------------------- /devrequirements_1.11.txt: -------------------------------------------------------------------------------- 1 | -r devrequirements.txt 2 | django-contrib-comments>=1.8.0 3 | Django>=1.11,<1.12 4 | -------------------------------------------------------------------------------- /devrequirements_1.5.txt: -------------------------------------------------------------------------------- 1 | -r devrequirements.txt 2 | Django>=1.5,<1.6 3 | -------------------------------------------------------------------------------- /devrequirements_1.6.txt: -------------------------------------------------------------------------------- 1 | -r devrequirements.txt 2 | Django>=1.6,<1.7 3 | -------------------------------------------------------------------------------- /devrequirements_1.7.txt: -------------------------------------------------------------------------------- 1 | -r devrequirements.txt 2 | Django>=1.7,<1.8 3 | -------------------------------------------------------------------------------- /devrequirements_1.8.txt: -------------------------------------------------------------------------------- 1 | -r devrequirements.txt 2 | django-contrib-comments>=1.6.1,<1.7.0 3 | Django>=1.8,<1.9 4 | -------------------------------------------------------------------------------- /devrequirements_1.9.txt: -------------------------------------------------------------------------------- 1 | -r devrequirements.txt 2 | django-contrib-comments>=1.6.2,<1.7.0 3 | Django>=1.9,<1.10 4 | -------------------------------------------------------------------------------- /devrequirements_2.0.txt: -------------------------------------------------------------------------------- 1 | -r devrequirements.txt 2 | django-contrib-comments>=1.8.0 3 | Django>=2.0,<2.1 4 | -------------------------------------------------------------------------------- /rest_hooks/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = (1, 6, 0) 2 | -------------------------------------------------------------------------------- /rest_hooks/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.conf import settings 3 | from django import forms 4 | from rest_hooks.utils import get_hook_model 5 | 6 | if getattr(settings, 'HOOK_EVENTS', None) is None: 7 | raise Exception("You need to define settings.HOOK_EVENTS!") 8 | 9 | 10 | HookModel = get_hook_model() 11 | 12 | 13 | class HookForm(forms.ModelForm): 14 | """ 15 | Model form to handle registered events, asuring 16 | only events declared on HOOK_EVENTS settings 17 | can be registered. 18 | """ 19 | 20 | class Meta: 21 | model = HookModel 22 | fields = ['user', 'target', 'event'] 23 | 24 | def __init__(self, *args, **kwargs): 25 | super(HookForm, self).__init__(*args, **kwargs) 26 | self.fields['event'] = forms.ChoiceField(choices=self.get_admin_events()) 27 | 28 | @classmethod 29 | def get_admin_events(cls): 30 | return [(x, x) for x in getattr(settings, 'HOOK_EVENTS', None).keys()] 31 | 32 | 33 | class HookAdmin(admin.ModelAdmin): 34 | list_display = [f.name for f in HookModel._meta.fields] 35 | raw_id_fields = ['user', ] 36 | form = HookForm 37 | 38 | 39 | admin.site.register(HookModel, HookAdmin) 40 | -------------------------------------------------------------------------------- /rest_hooks/client.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import collections 3 | 4 | import requests 5 | 6 | 7 | class FlushThread(threading.Thread): 8 | def __init__(self, client): 9 | threading.Thread.__init__(self) 10 | self.client = client 11 | 12 | def run(self): 13 | self.client.sync_flush() 14 | 15 | 16 | class Client(object): 17 | """ 18 | Manages a simple pool of threads to flush the queue of requests. 19 | """ 20 | def __init__(self, num_threads=3): 21 | self.queue = collections.deque() 22 | 23 | self.flush_lock = threading.Lock() 24 | self.num_threads = num_threads 25 | self.flush_threads = [FlushThread(self) for _ in range(self.num_threads)] 26 | self.total_sent = 0 27 | 28 | def enqueue(self, method, *args, **kwargs): 29 | self.queue.append((method, args, kwargs)) 30 | self.refresh_threads() 31 | 32 | def get(self, *args, **kwargs): 33 | self.enqueue('get', *args, **kwargs) 34 | 35 | def post(self, *args, **kwargs): 36 | self.enqueue('post', *args, **kwargs) 37 | 38 | def put(self, *args, **kwargs): 39 | self.enqueue('put', *args, **kwargs) 40 | 41 | def delete(self, *args, **kwargs): 42 | self.enqueue('delete', *args, **kwargs) 43 | 44 | def refresh_threads(self): 45 | with self.flush_lock: 46 | # refresh if there are jobs to do and no threads are alive 47 | if len(self.queue) > 0: 48 | to_refresh = [index for index, thread in enumerate(self.flush_threads) if not thread.is_alive()] 49 | for index in to_refresh: 50 | self.flush_threads[index] = FlushThread(self) 51 | self.flush_threads[index].start() 52 | 53 | def sync_flush(self): 54 | session = requests.Session() 55 | while self.queue: 56 | method, args, kwargs = self.queue.pop() 57 | getattr(session, method)(*args, **kwargs) 58 | self.total_sent += 1 59 | -------------------------------------------------------------------------------- /rest_hooks/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import django.db.models.deletion 5 | from django.db import models, migrations 6 | from django.conf import settings 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Hook', 18 | fields=[ 19 | ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), 20 | ('created', models.DateTimeField(auto_now_add=True)), 21 | ('updated', models.DateTimeField(auto_now=True)), 22 | ('event', models.CharField(max_length=64, verbose_name='Event', db_index=True)), 23 | ('target', models.URLField(max_length=255, verbose_name='Target URL')), 24 | ('user', models.ForeignKey(related_name='hooks', to=settings.AUTH_USER_MODEL, on_delete=django.db.models.deletion.CASCADE)), 25 | ], 26 | options={ 27 | 'swappable': 'HOOK_CUSTOM_MODEL', 28 | }, 29 | bases=(models.Model,), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /rest_hooks/migrations/0002_swappable_hook_model.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from django.db import migrations, models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | dependencies = [ 8 | ('rest_hooks', '0001_initial'), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterModelOptions( 13 | name='Hook', 14 | options={ 15 | 'swappable': 'HOOK_CUSTOM_MODEL', 16 | }, 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /rest_hooks/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zapier/django-rest-hooks/fbe0a0f1cc6a39474465dec939edafa51723895d/rest_hooks/migrations/__init__.py -------------------------------------------------------------------------------- /rest_hooks/models.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | import requests 4 | 5 | import django 6 | from django.conf import settings 7 | from django.core import serializers 8 | from django.core.exceptions import ValidationError, ImproperlyConfigured 9 | from django.core.serializers.json import DjangoJSONEncoder 10 | from django.db import models 11 | from django.db.models.signals import post_save, post_delete 12 | from django.test.signals import setting_changed 13 | from django.dispatch import receiver 14 | 15 | try: 16 | # Django <= 1.6 backwards compatibility 17 | from django.utils import simplejson as json 18 | except ImportError: 19 | # Django >= 1.7 20 | import json 21 | 22 | from rest_hooks.signals import hook_event, raw_hook_event, hook_sent_event 23 | from rest_hooks.utils import distill_model_event, get_hook_model, get_module, find_and_fire_hook 24 | 25 | 26 | if getattr(settings, 'HOOK_CUSTOM_MODEL', None) is None: 27 | settings.HOOK_CUSTOM_MODEL = 'rest_hooks.Hook' 28 | 29 | HOOK_EVENTS = getattr(settings, 'HOOK_EVENTS', None) 30 | if HOOK_EVENTS is None: 31 | raise Exception('You need to define settings.HOOK_EVENTS!') 32 | 33 | _HOOK_EVENT_ACTIONS_CONFIG = None 34 | 35 | 36 | def get_event_actions_config(): 37 | global _HOOK_EVENT_ACTIONS_CONFIG 38 | if _HOOK_EVENT_ACTIONS_CONFIG is None: 39 | _HOOK_EVENT_ACTIONS_CONFIG = {} 40 | for event_name, auto in HOOK_EVENTS.items(): 41 | if not auto: 42 | continue 43 | model_label, action = auto.rsplit('.', 1) 44 | action_parts = action.rsplit('+', 1) 45 | action = action_parts[0] 46 | ignore_user_override = False 47 | if len(action_parts) == 2: 48 | ignore_user_override = True 49 | 50 | model_config = _HOOK_EVENT_ACTIONS_CONFIG.setdefault(model_label, {}) 51 | if action in model_config: 52 | raise ImproperlyConfigured( 53 | "settings.HOOK_EVENTS have a dublicate {action} for model " 54 | "{model_label}".format(action=action, model_label=model_label) 55 | ) 56 | model_config[action] = (event_name, ignore_user_override,) 57 | return _HOOK_EVENT_ACTIONS_CONFIG 58 | 59 | 60 | if getattr(settings, 'HOOK_THREADING', True): 61 | from rest_hooks.client import Client 62 | client = Client() 63 | else: 64 | client = requests.Session() 65 | 66 | AUTH_USER_MODEL = getattr(settings, 'AUTH_USER_MODEL', 'auth.User') 67 | 68 | 69 | class AbstractHook(models.Model): 70 | """ 71 | Stores a representation of a Hook. 72 | """ 73 | created = models.DateTimeField(auto_now_add=True) 74 | updated = models.DateTimeField(auto_now=True) 75 | 76 | user = models.ForeignKey(AUTH_USER_MODEL, related_name='%(class)ss', on_delete=models.CASCADE) 77 | event = models.CharField('Event', max_length=64, db_index=True) 78 | target = models.URLField('Target URL', max_length=255) 79 | 80 | class Meta: 81 | abstract = True 82 | 83 | def clean(self): 84 | """ Validation for events. """ 85 | if self.event not in HOOK_EVENTS.keys(): 86 | raise ValidationError( 87 | "Invalid hook event {evt}.".format(evt=self.event) 88 | ) 89 | 90 | def dict(self): 91 | return { 92 | 'id': self.id, 93 | 'event': self.event, 94 | 'target': self.target 95 | } 96 | 97 | def serialize_hook(self, instance): 98 | """ 99 | Serialize the object down to Python primitives. 100 | 101 | By default it uses Django's built in serializer. 102 | """ 103 | if getattr(instance, 'serialize_hook', None) and callable(instance.serialize_hook): 104 | return instance.serialize_hook(hook=self) 105 | if getattr(settings, 'HOOK_SERIALIZER', None): 106 | serializer = get_module(settings.HOOK_SERIALIZER) 107 | return serializer(instance, hook=self) 108 | # if no user defined serializers, fallback to the django builtin! 109 | data = serializers.serialize('python', [instance])[0] 110 | for k, v in data.items(): 111 | if isinstance(v, OrderedDict): 112 | data[k] = dict(v) 113 | 114 | if isinstance(data, OrderedDict): 115 | data = dict(data) 116 | 117 | return { 118 | 'hook': self.dict(), 119 | 'data': data, 120 | } 121 | 122 | def deliver_hook(self, instance, payload_override=None): 123 | """ 124 | Deliver the payload to the target URL. 125 | 126 | By default it serializes to JSON and POSTs. 127 | 128 | Args: 129 | instance: instance that triggered event. 130 | payload_override: JSON-serializable object or callable that will 131 | return such object. If callable is used it should accept 2 132 | arguments: `hook` and `instance`. 133 | """ 134 | if payload_override is None: 135 | payload = self.serialize_hook(instance) 136 | else: 137 | payload = payload_override 138 | 139 | if callable(payload): 140 | payload = payload(self, instance) 141 | 142 | if getattr(settings, 'HOOK_DELIVERER', None): 143 | deliverer = get_module(settings.HOOK_DELIVERER) 144 | deliverer(self.target, payload, instance=instance, hook=self) 145 | else: 146 | client.post( 147 | url=self.target, 148 | data=json.dumps(payload, cls=DjangoJSONEncoder), 149 | headers={'Content-Type': 'application/json'} 150 | ) 151 | 152 | hook_sent_event.send_robust(sender=self.__class__, payload=payload, instance=instance, hook=self) 153 | return None 154 | 155 | def __unicode__(self): 156 | return u'{} => {}'.format(self.event, self.target) 157 | 158 | 159 | class Hook(AbstractHook): 160 | if django.VERSION >= (1, 7): 161 | class Meta(AbstractHook.Meta): 162 | swappable = 'HOOK_CUSTOM_MODEL' 163 | 164 | 165 | 166 | ############## 167 | ### EVENTS ### 168 | ############## 169 | 170 | 171 | def get_model_label(instance): 172 | if instance is None: 173 | return None 174 | opts = instance._meta.concrete_model._meta 175 | try: 176 | return opts.label 177 | except AttributeError: 178 | return '.'.join([opts.app_label, opts.object_name]) 179 | 180 | 181 | @receiver(post_save, dispatch_uid='instance-saved-hook') 182 | def model_saved(sender, instance, 183 | created, 184 | raw, 185 | using, 186 | **kwargs): 187 | """ 188 | Automatically triggers "created" and "updated" actions. 189 | """ 190 | model_label = get_model_label(instance) 191 | action = 'created' if created else 'updated' 192 | distill_model_event(instance, model_label, action) 193 | 194 | 195 | @receiver(post_delete, dispatch_uid='instance-deleted-hook') 196 | def model_deleted(sender, instance, 197 | using, 198 | **kwargs): 199 | """ 200 | Automatically triggers "deleted" actions. 201 | """ 202 | model_label = get_model_label(instance) 203 | distill_model_event(instance, model_label, 'deleted') 204 | 205 | 206 | @receiver(hook_event, dispatch_uid='instance-custom-hook') 207 | def custom_action(sender, action, 208 | instance, 209 | user=None, 210 | **kwargs): 211 | """ 212 | Manually trigger a custom action (or even a standard action). 213 | """ 214 | model_label = get_model_label(instance) 215 | distill_model_event(instance, model_label, action, user_override=user) 216 | 217 | 218 | @receiver(raw_hook_event, dispatch_uid='raw-custom-hook') 219 | def raw_custom_event( 220 | sender, 221 | event_name, 222 | payload, 223 | user, 224 | send_hook_meta=True, 225 | instance=None, 226 | trust_event_name=False, 227 | **kwargs 228 | ): 229 | """ 230 | Give a full payload 231 | """ 232 | model_label = get_model_label(instance) 233 | 234 | new_payload = payload 235 | 236 | if send_hook_meta: 237 | new_payload = lambda hook, instance: { 238 | 'hook': hook.dict(), 239 | 'data': payload 240 | } 241 | 242 | distill_model_event( 243 | instance, 244 | model_label, 245 | None, 246 | user_override=user, 247 | event_name=event_name, 248 | trust_event_name=trust_event_name, 249 | payload_override=new_payload, 250 | ) 251 | 252 | 253 | @receiver(setting_changed) 254 | def handle_hook_events_change(sender, setting, *args, **kwargs): 255 | global _HOOK_EVENT_ACTIONS_CONFIG 256 | global HOOK_EVENTS 257 | if setting == 'HOOK_EVENTS': 258 | _HOOK_EVENT_ACTIONS_CONFIG = None 259 | HOOK_EVENTS = settings.HOOK_EVENTS 260 | -------------------------------------------------------------------------------- /rest_hooks/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | 4 | hook_event = Signal(providing_args=['action', 'instance']) 5 | raw_hook_event = Signal(providing_args=['event_name', 'payload', 'user']) 6 | hook_sent_event = Signal(providing_args=['payload', 'instance', 'hook']) 7 | -------------------------------------------------------------------------------- /rest_hooks/south_migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from south.utils import datetime_utils as datetime 3 | from south.db import db 4 | from south.v2 import SchemaMigration 5 | from django.db import models 6 | 7 | 8 | class Migration(SchemaMigration): 9 | 10 | def forwards(self, orm): 11 | # Adding model 'Hook' 12 | db.create_table('rest_hooks_hook', ( 13 | ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), 14 | ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), 15 | ('updated', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)), 16 | ('user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='hooks', to=orm['auth.User'])), 17 | ('event', self.gf('django.db.models.fields.CharField')(max_length=64, db_index=True)), 18 | ('target', self.gf('django.db.models.fields.URLField')(max_length=255)), 19 | )) 20 | db.send_create_signal('rest_hooks', ['Hook']) 21 | 22 | 23 | def backwards(self, orm): 24 | # Deleting model 'Hook' 25 | db.delete_table('rest_hooks_hook') 26 | 27 | 28 | models = { 29 | 'auth.group': { 30 | 'Meta': {'object_name': 'Group'}, 31 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 32 | 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), 33 | 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) 34 | }, 35 | 'auth.permission': { 36 | 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, 37 | 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 38 | 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), 39 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 40 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) 41 | }, 42 | 'auth.user': { 43 | 'Meta': {'object_name': 'User'}, 44 | 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 45 | 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 46 | 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 47 | 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), 48 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 49 | 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 50 | 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 51 | 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 52 | 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 53 | 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 54 | 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), 55 | 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), 56 | 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) 57 | }, 58 | 'contenttypes.contenttype': { 59 | 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, 60 | 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 61 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 62 | 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 63 | 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) 64 | }, 65 | 'rest_hooks.hook': { 66 | 'Meta': {'object_name': 'Hook'}, 67 | 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 68 | 'event': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), 69 | 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 70 | 'target': ('django.db.models.fields.URLField', [], {'max_length': '255'}), 71 | 'updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), 72 | 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'hooks'", 'to': "orm['auth.User']"}) 73 | } 74 | } 75 | 76 | complete_apps = ['rest_hooks'] -------------------------------------------------------------------------------- /rest_hooks/south_migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zapier/django-rest-hooks/fbe0a0f1cc6a39474465dec939edafa51723895d/rest_hooks/south_migrations/__init__.py -------------------------------------------------------------------------------- /rest_hooks/tasks.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import json 3 | 4 | from celery.task import Task 5 | 6 | from django.core.serializers.json import DjangoJSONEncoder 7 | 8 | from rest_hooks.utils import get_hook_model 9 | 10 | 11 | class DeliverHook(Task): 12 | def run(self, target, payload, instance=None, hook_id=None, **kwargs): 13 | """ 14 | target: the url to receive the payload. 15 | payload: a python primitive data structure 16 | instance: a possibly null "trigger" instance 17 | hook: the defining Hook object (useful for removing) 18 | """ 19 | response = requests.post( 20 | url=target, 21 | data=json.dumps(payload, cls=DjangoJSONEncoder), 22 | headers={'Content-Type': 'application/json'} 23 | ) 24 | 25 | if response.status_code == 410 and hook_id: 26 | HookModel = get_hook_model() 27 | hook = HookModel.object.get(id=hook_id) 28 | hook.delete() 29 | 30 | # would be nice to log this, at least for a little while... 31 | 32 | def deliver_hook_wrapper(target, payload, instance=None, hook=None, **kwargs): 33 | if hook: 34 | kwargs['hook_id'] = hook.id 35 | return DeliverHook.delay(target, payload, **kwargs) 36 | -------------------------------------------------------------------------------- /rest_hooks/tests.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import time 3 | from mock import patch, MagicMock, ANY 4 | 5 | from datetime import datetime 6 | 7 | try: 8 | # Django <= 1.6 backwards compatibility 9 | from django.utils import simplejson as json 10 | except ImportError: 11 | # Django >= 1.7 12 | import json 13 | 14 | from django.contrib.auth.models import User 15 | from django.contrib.sites.models import Site 16 | from django.test import TestCase 17 | from django.test.utils import override_settings 18 | try: 19 | from django.contrib.comments.models import Comment 20 | comments_app_label = 'comments' 21 | except ImportError: 22 | from django_comments.models import Comment 23 | comments_app_label = 'django_comments' 24 | 25 | from rest_hooks import models 26 | from rest_hooks import signals 27 | from rest_hooks.admin import HookForm 28 | 29 | Hook = models.Hook 30 | 31 | 32 | urlpatterns = [] 33 | HOOK_EVENTS_OVERRIDE = { 34 | 'comment.added': comments_app_label + '.Comment.created', 35 | 'comment.changed': comments_app_label + '.Comment.updated', 36 | 'comment.removed': comments_app_label + '.Comment.deleted', 37 | 'comment.moderated': comments_app_label + '.Comment.moderated', 38 | 'special.thing': None, 39 | } 40 | 41 | ALT_HOOK_EVENTS = dict(HOOK_EVENTS_OVERRIDE) 42 | ALT_HOOK_EVENTS['comment.moderated'] += '+' 43 | 44 | 45 | @override_settings(HOOK_EVENTS=HOOK_EVENTS_OVERRIDE, HOOK_DELIVERER=None) 46 | class RESTHooksTest(TestCase): 47 | """ 48 | This test Class uses real HTTP calls to a requestbin service, making it easy 49 | to check responses and endpoint history. 50 | """ 51 | 52 | ############# 53 | ### TOOLS ### 54 | ############# 55 | 56 | def setUp(self): 57 | self.client = requests # force non-async for test cases 58 | 59 | self.user = User.objects.create_user('bob', 'bob@example.com', 'password') 60 | self.site, created = Site.objects.get_or_create(domain='example.com', name='example.com') 61 | 62 | def make_hook(self, event, target): 63 | return Hook.objects.create( 64 | user=self.user, 65 | event=event, 66 | target=target 67 | ) 68 | 69 | ############# 70 | ### TESTS ### 71 | ############# 72 | 73 | @override_settings(HOOK_EVENTS=ALT_HOOK_EVENTS) 74 | def test_get_event_actions_config(self): 75 | self.assertEquals( 76 | models.get_event_actions_config(), 77 | { 78 | comments_app_label + '.Comment': { 79 | 'created': ('comment.added', False), 80 | 'updated': ('comment.changed', False), 81 | 'deleted': ('comment.removed', False), 82 | 'moderated': ('comment.moderated', True), 83 | }, 84 | } 85 | ) 86 | 87 | def test_no_user_property_fail(self): 88 | with self.assertRaises(Exception): 89 | models.find_and_fire_hook('some.fake.event', self.user) 90 | 91 | models.find_and_fire_hook('special.thing', self.user) 92 | 93 | def test_no_hook(self): 94 | comment = Comment.objects.create( 95 | site=self.site, 96 | content_object=self.user, 97 | user=self.user, 98 | comment='Hello world!' 99 | ) 100 | 101 | @patch('rest_hooks.models.client.post', autospec=True) 102 | def perform_create_request_cycle(self, method_mock): 103 | method_mock.return_value = None 104 | 105 | target = 'http://example.com/perform_create_request_cycle' 106 | hook = self.make_hook('comment.added', target) 107 | 108 | comment = Comment.objects.create( 109 | site=self.site, 110 | content_object=self.user, 111 | user=self.user, 112 | comment='Hello world!' 113 | ) 114 | # time.sleep(1) # should change a setting to turn off async 115 | 116 | return hook, comment, json.loads(method_mock.call_args_list[0][1]['data']) 117 | 118 | def test_simple_comment_hook(self): 119 | """ 120 | Uses the default serializer. 121 | """ 122 | hook, comment, payload = self.perform_create_request_cycle() 123 | 124 | self.assertEquals(hook.id, payload['hook']['id']) 125 | self.assertEquals('comment.added', payload['hook']['event']) 126 | self.assertEquals(hook.target, payload['hook']['target']) 127 | 128 | self.assertEquals(comment.id, payload['data']['pk']) 129 | self.assertEquals('Hello world!', payload['data']['fields']['comment']) 130 | self.assertEquals(comment.user.id, payload['data']['fields']['user']) 131 | 132 | def test_comment_hook_serializer_method(self): 133 | """ 134 | Use custom serialize_hook on the Comment model. 135 | """ 136 | def serialize_hook(comment, hook): 137 | return { 'hook': hook.dict(), 138 | 'data': { 'id': comment.id, 139 | 'comment': comment.comment, 140 | 'user': { 'username': comment.user.username, 141 | 'email': comment.user.email}}} 142 | Comment.serialize_hook = serialize_hook 143 | hook, comment, payload = self.perform_create_request_cycle() 144 | 145 | self.assertEquals(hook.id, payload['hook']['id']) 146 | self.assertEquals('comment.added', payload['hook']['event']) 147 | self.assertEquals(hook.target, payload['hook']['target']) 148 | 149 | self.assertEquals(comment.id, payload['data']['id']) 150 | self.assertEquals('Hello world!', payload['data']['comment']) 151 | self.assertEquals('bob', payload['data']['user']['username']) 152 | 153 | del Comment.serialize_hook 154 | 155 | @patch('rest_hooks.models.client.post') 156 | def test_full_cycle_comment_hook(self, method_mock): 157 | method_mock.return_value = None 158 | target = 'http://example.com/test_full_cycle_comment_hook' 159 | 160 | hooks = [self.make_hook(event, target) for event in ['comment.added', 'comment.changed', 'comment.removed']] 161 | 162 | # created 163 | comment = Comment.objects.create( 164 | site=self.site, 165 | content_object=self.user, 166 | user=self.user, 167 | comment='Hello world!' 168 | ) 169 | # time.sleep(0.5) # should change a setting to turn off async 170 | 171 | # updated 172 | comment.comment = 'Goodbye world...' 173 | comment.save() 174 | # time.sleep(0.5) # should change a setting to turn off async 175 | 176 | # deleted 177 | comment.delete() 178 | # time.sleep(0.5) # should change a setting to turn off async 179 | 180 | payloads = [json.loads(call[2]['data']) for call in method_mock.mock_calls] 181 | 182 | self.assertEquals('comment.added', payloads[0]['hook']['event']) 183 | self.assertEquals('comment.changed', payloads[1]['hook']['event']) 184 | self.assertEquals('comment.removed', payloads[2]['hook']['event']) 185 | 186 | self.assertEquals('Hello world!', payloads[0]['data']['fields']['comment']) 187 | self.assertEquals('Goodbye world...', payloads[1]['data']['fields']['comment']) 188 | self.assertEquals('Goodbye world...', payloads[2]['data']['fields']['comment']) 189 | 190 | @patch('rest_hooks.models.client.post') 191 | def test_custom_instance_hook(self, method_mock): 192 | from rest_hooks.signals import hook_event 193 | 194 | method_mock.return_value = None 195 | target = 'http://example.com/test_custom_instance_hook' 196 | 197 | hook = self.make_hook('comment.moderated', target) 198 | 199 | comment = Comment.objects.create( 200 | site=self.site, 201 | content_object=self.user, 202 | user=self.user, 203 | comment='Hello world!' 204 | ) 205 | 206 | hook_event.send( 207 | sender=comment.__class__, 208 | action='moderated', 209 | instance=comment 210 | ) 211 | # time.sleep(1) # should change a setting to turn off async 212 | 213 | payloads = [json.loads(call[2]['data']) for call in method_mock.mock_calls] 214 | 215 | self.assertEquals('comment.moderated', payloads[0]['hook']['event']) 216 | self.assertEquals('Hello world!', payloads[0]['data']['fields']['comment']) 217 | 218 | @patch('rest_hooks.models.client.post') 219 | def test_raw_custom_event(self, method_mock): 220 | from rest_hooks.signals import raw_hook_event 221 | 222 | method_mock.return_value = None 223 | target = 'http://example.com/test_raw_custom_event' 224 | 225 | hook = self.make_hook('special.thing', target) 226 | 227 | raw_hook_event.send( 228 | sender=None, 229 | event_name='special.thing', 230 | payload={ 231 | 'hello': 'world!' 232 | }, 233 | user=self.user 234 | ) 235 | # time.sleep(1) # should change a setting to turn off async 236 | 237 | payload = json.loads(method_mock.mock_calls[0][2]['data']) 238 | 239 | self.assertEquals('special.thing', payload['hook']['event']) 240 | self.assertEquals('world!', payload['data']['hello']) 241 | 242 | def test_timed_cycle(self): 243 | return # basically a debug test for thread pool bit 244 | target = 'http://requestbin.zapier.com/api/v1/bin/test_timed_cycle' 245 | 246 | hooks = [self.make_hook(event, target) for event in ['comment.added', 'comment.changed', 'comment.removed']] 247 | 248 | for n in range(4): 249 | early = datetime.now() 250 | # fires N * 3 http calls 251 | for x in range(10): 252 | comment = Comment.objects.create( 253 | site=self.site, 254 | content_object=self.user, 255 | user=self.user, 256 | comment='Hello world!' 257 | ) 258 | comment.comment = 'Goodbye world...' 259 | comment.save() 260 | comment.delete() 261 | total = datetime.now() - early 262 | 263 | print(total) 264 | 265 | while True: 266 | response = requests.get(target + '/view') 267 | sent = response.json 268 | if sent: 269 | print(len(sent), models.async_requests.total_sent) 270 | if models.async_requests.total_sent >= (30 * (n+1)): 271 | time.sleep(5) 272 | break 273 | time.sleep(1) 274 | 275 | requests.delete(target + '/view') # cleanup to be polite 276 | 277 | def test_signal_emitted_upon_success(self): 278 | wrapper = lambda *args, **kwargs: None 279 | mock_handler = MagicMock(wraps=wrapper) 280 | 281 | signals.hook_sent_event.connect(mock_handler, sender=Hook) 282 | 283 | hook, comment, payload = self.perform_create_request_cycle() 284 | 285 | payload['data']['fields']['submit_date'] = ANY 286 | mock_handler.assert_called_with(signal=ANY, sender=Hook, payload=payload, instance=comment, hook=hook) 287 | 288 | def test_valid_form(self): 289 | 290 | form_data = { 291 | 'user': self.user.id, 292 | 'target': "http://example.com", 293 | 'event': HookForm.get_admin_events()[0][0] 294 | } 295 | form = HookForm(data=form_data) 296 | self.assertTrue(form.is_valid()) 297 | 298 | def test_form_save(self): 299 | form_data = { 300 | 'user': self.user.id, 301 | 'target': "http://example.com", 302 | 'event': HookForm.get_admin_events()[0][0] 303 | } 304 | form = HookForm(data=form_data) 305 | 306 | self.assertTrue(form.is_valid()) 307 | instance = form.save() 308 | self.assertIsInstance(instance, Hook) 309 | 310 | def test_invalid_form(self): 311 | form = HookForm(data={}) 312 | self.assertFalse(form.is_valid()) 313 | 314 | @override_settings(HOOK_CUSTOM_MODEL='rest_hooks.models.Hook') 315 | def test_get_custom_hook_model(self): 316 | # Using the default Hook model just to exercise get_hook_model's 317 | # lookup machinery. 318 | from rest_hooks.utils import get_hook_model 319 | from rest_hooks.models import AbstractHook 320 | HookModel = get_hook_model() 321 | self.assertIs(HookModel, Hook) 322 | self.assertTrue(issubclass(HookModel, AbstractHook)) 323 | -------------------------------------------------------------------------------- /rest_hooks/utils.py: -------------------------------------------------------------------------------- 1 | import django 2 | 3 | try: 4 | from django.apps import apps as django_apps 5 | except ImportError: 6 | django_apps = None 7 | from django.core.exceptions import ImproperlyConfigured 8 | from django.conf import settings 9 | 10 | if django.VERSION >= (2, 0,): 11 | get_model_kwargs = {'require_ready': False} 12 | else: 13 | get_model_kwargs = {} 14 | 15 | 16 | def get_module(path): 17 | """ 18 | A modified duplicate from Django's built in backend 19 | retriever. 20 | 21 | slugify = get_module('django.template.defaultfilters.slugify') 22 | """ 23 | try: 24 | from importlib import import_module 25 | except ImportError as e: 26 | from django.utils.importlib import import_module 27 | 28 | try: 29 | mod_name, func_name = path.rsplit('.', 1) 30 | mod = import_module(mod_name) 31 | except ImportError as e: 32 | raise ImportError( 33 | 'Error importing alert function {0}: "{1}"'.format(mod_name, e)) 34 | 35 | try: 36 | func = getattr(mod, func_name) 37 | except AttributeError: 38 | raise ImportError( 39 | ('Module "{0}" does not define a "{1}" function' 40 | ).format(mod_name, func_name)) 41 | 42 | return func 43 | 44 | 45 | def get_hook_model(): 46 | """ 47 | Returns the Custom Hook model if defined in settings, 48 | otherwise the default Hook model. 49 | """ 50 | model_label = getattr(settings, 'HOOK_CUSTOM_MODEL', None) 51 | if django_apps: 52 | model_label = (model_label or 'rest_hooks.Hook').replace('.models.', '.') 53 | try: 54 | return django_apps.get_model(model_label, **get_model_kwargs) 55 | except ValueError: 56 | raise ImproperlyConfigured("HOOK_CUSTOM_MODEL must be of the form 'app_label.model_name'") 57 | except LookupError: 58 | raise ImproperlyConfigured( 59 | "HOOK_CUSTOM_MODEL refers to model '%s' that has not been installed" % model_label 60 | ) 61 | else: 62 | if model_label in (None, 'rest_hooks.Hook'): 63 | from rest_hooks.models import Hook 64 | HookModel = Hook 65 | else: 66 | try: 67 | HookModel = get_module(settings.HOOK_CUSTOM_MODEL) 68 | except ImportError: 69 | raise ImproperlyConfigured( 70 | "HOOK_CUSTOM_MODEL refers to model '%s' that cannot be imported" % model_label 71 | ) 72 | return HookModel 73 | 74 | 75 | 76 | def find_and_fire_hook(event_name, instance, user_override=None, payload_override=None): 77 | """ 78 | Look up Hooks that apply 79 | """ 80 | try: 81 | from django.contrib.auth import get_user_model 82 | User = get_user_model() 83 | except ImportError: 84 | from django.contrib.auth.models import User 85 | from rest_hooks.models import HOOK_EVENTS 86 | 87 | if event_name not in HOOK_EVENTS.keys(): 88 | raise Exception( 89 | '"{}" does not exist in `settings.HOOK_EVENTS`.'.format(event_name) 90 | ) 91 | 92 | filters = {'event': event_name} 93 | 94 | # Ignore the user if the user_override is False 95 | if user_override is not False: 96 | if user_override: 97 | filters['user'] = user_override 98 | elif hasattr(instance, 'user'): 99 | filters['user'] = instance.user 100 | elif isinstance(instance, User): 101 | filters['user'] = instance 102 | else: 103 | raise Exception( 104 | '{} has no `user` property. REST Hooks needs this.'.format(repr(instance)) 105 | ) 106 | # NOTE: This is probably up for discussion, but I think, in this 107 | # case, instead of raising an error, we should fire the hook for 108 | # all users/accounts it is subscribed to. That would be a genuine 109 | # usecase rather than erroring because no user is associated with 110 | # this event. 111 | 112 | HookModel = get_hook_model() 113 | 114 | hooks = HookModel.objects.filter(**filters) 115 | for hook in hooks: 116 | hook.deliver_hook(instance, payload_override=payload_override) 117 | 118 | 119 | def distill_model_event( 120 | instance, 121 | model=False, 122 | action=False, 123 | user_override=None, 124 | event_name=False, 125 | trust_event_name=False, 126 | payload_override=None, 127 | ): 128 | """ 129 | Take `event_name` or determine it using action and model 130 | from settings.HOOK_EVENTS, and let hooks fly. 131 | 132 | if `event_name` is passed together with `model` or `action`, then 133 | they should be the same as in settings or `trust_event_name` should be 134 | `True` 135 | 136 | If event_name is not found or is invalidated, then just quit silently. 137 | 138 | If payload_override is passed, then it will be passed into HookModel.deliver_hook 139 | 140 | """ 141 | from rest_hooks.models import get_event_actions_config, HOOK_EVENTS 142 | 143 | if event_name is False and (model is False or action is False): 144 | raise TypeError( 145 | 'distill_model_event() requires either `event_name` argument or ' 146 | 'both `model` and `action` arguments.' 147 | ) 148 | if event_name: 149 | if trust_event_name: 150 | pass 151 | elif event_name in HOOK_EVENTS: 152 | auto = HOOK_EVENTS[event_name] 153 | if auto: 154 | allowed_model, allowed_action = auto.rsplit('.', 1) 155 | 156 | allowed_action_parts = allowed_action.rsplit('+', 1) 157 | allowed_action = allowed_action_parts[0] 158 | 159 | model = model or allowed_model 160 | action = action or allowed_action 161 | 162 | if not (model == allowed_model and action == allowed_action): 163 | event_name = None 164 | 165 | if len(allowed_action_parts) == 2: 166 | user_override = False 167 | else: 168 | event_actions_config = get_event_actions_config() 169 | 170 | event_name, ignore_user_override = event_actions_config.get(model, {}).get(action, (None, False)) 171 | if ignore_user_override: 172 | user_override = False 173 | 174 | if event_name: 175 | if getattr(settings, 'HOOK_FINDER', None): 176 | finder = get_module(settings.HOOK_FINDER) 177 | else: 178 | finder = find_and_fire_hook 179 | finder(event_name, instance, user_override=user_override, payload_override=payload_override) 180 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | import django 5 | from django.conf import settings 6 | 7 | 8 | APP_NAME = 'rest_hooks' 9 | if django.VERSION < (1, 8): 10 | comments = 'django.contrib.comments' 11 | else: 12 | comments = 'django_comments' 13 | 14 | settings.configure( 15 | DEBUG=True, 16 | DATABASES={ 17 | 'default': { 18 | 'ENGINE': 'django.db.backends.sqlite3', 19 | } 20 | }, 21 | USE_TZ=True, 22 | ROOT_URLCONF='{0}.tests'.format(APP_NAME), 23 | MIDDLEWARE_CLASSES=( 24 | 'django.contrib.sessions.middleware.SessionMiddleware', 25 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 26 | ), 27 | SITE_ID=1, 28 | HOOK_EVENTS={}, 29 | HOOK_THREADING=False, 30 | INSTALLED_APPS=( 31 | 'django.contrib.auth', 32 | 'django.contrib.contenttypes', 33 | 'django.contrib.sessions', 34 | 'django.contrib.admin', 35 | 'django.contrib.sites', 36 | comments, 37 | APP_NAME, 38 | ), 39 | ) 40 | 41 | from django.test.utils import get_runner 42 | 43 | if hasattr(django, 'setup'): 44 | django.setup() 45 | TestRunner = get_runner(settings) 46 | test_runner = TestRunner() 47 | failures = test_runner.run_tests([APP_NAME]) 48 | if failures: 49 | sys.exit(failures) 50 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | try: 2 | from setuptools import setup 3 | except ImportError: 4 | from distutils.core import setup # if setuptools breaks 5 | 6 | # Dynamically calculate the version 7 | version_tuple = __import__('rest_hooks').VERSION 8 | version = '.'.join([str(v) for v in version_tuple]) 9 | 10 | setup( 11 | name = 'django-rest-hooks', 12 | description = 'A powerful mechanism for sending real time API notifications via a new subscription model.', 13 | version = version, 14 | author = 'Bryan Helmig', 15 | author_email = 'bryan@zapier.com', 16 | url = 'http://github.com/zapier/django-rest-hooks', 17 | install_requires=['Django>=1.5', 'requests'], 18 | packages=['rest_hooks'], 19 | package_data={ 20 | 'rest_hooks': [ 21 | 'migrations/*.py', 22 | 'south_migrations/*.py' 23 | ] 24 | }, 25 | classifiers = [ 26 | 'Development Status :: 5 - Production/Stable', 27 | 'Environment :: Web Environment', 28 | 'Framework :: Django', 29 | 'Intended Audience :: Developers', 30 | 'License :: OSI Approved :: BSD License', 31 | 'Operating System :: OS Independent', 32 | 'Programming Language :: Python', 33 | 'Programming Language :: Python :: 2', 34 | 'Programming Language :: Python :: 3', 35 | 'Topic :: Utilities', 36 | ], 37 | ) 38 | --------------------------------------------------------------------------------