pax_global_header00006660000000000000000000000064134665545540014533gustar00rootroot0000000000000052 comment=d21a070b9a099b5e9c755b2afa6b81c9d8b26e7c django-rest-hooks-1.6.0/000077500000000000000000000000001346655455400150755ustar00rootroot00000000000000django-rest-hooks-1.6.0/.gitignore000066400000000000000000000033671346655455400170760ustar00rootroot00000000000000# Created by https://www.gitignore.io/api/pycharm,python ### PyCharm ### # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm *.iml ## Directory-based project format: .idea/ # if you remove the above rule, at least ignore the following: # User-specific stuff: # .idea/workspace.xml # .idea/tasks.xml # .idea/dictionaries # .idea/shelf # Sensitive or high-churn files: # .idea/dataSources.ids # .idea/dataSources.xml # .idea/sqlDataSources.xml # .idea/dynamic.xml # .idea/uiDesigner.xml # Gradle: # .idea/gradle.xml # .idea/libraries # Mongo Explorer plugin: # .idea/mongoSettings.xml ## File-based project format: *.ipr *.iws ## Plugin-specific files: # IntelliJ /out/ # mpeltonen/sbt-idea plugin .idea_modules/ # JIRA plugin atlassian-ide-plugin.xml # Crashlytics plugin (for Android Studio and IntelliJ) com_crashlytics_export_strings.xml crashlytics.properties crashlytics-build.properties fabric.properties ### Python ### # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *,cover .hypothesis/ # Translations *.mo *.pot # Django stuff: *.log # Sphinx documentation docs/_build/ # PyBuilder target/ #SQLite *.sqlite .sass-cache #Test Coverage tests/reports/ django-rest-hooks-1.6.0/.travis.yml000066400000000000000000000023031346655455400172040ustar00rootroot00000000000000language: python python: - "2.7" - "3.3" - "3.4" - "3.6" env: - DJANGO_VERSION="1.5" - DJANGO_VERSION="1.6" - DJANGO_VERSION="1.7" - DJANGO_VERSION="1.8" - DJANGO_VERSION="1.9" - DJANGO_VERSION="1.10" - DJANGO_VERSION="1.11" - DJANGO_VERSION="2.0" install: - pip install -r devrequirements_${DJANGO_VERSION}.txt script: python runtests.py matrix: exclude: - python: "3.3" env: DJANGO_VERSION="1.9" - python: "3.3" env: DJANGO_VERSION="1.10" - python: "3.3" env: DJANGO_VERSION="1.11" - python: "3.3" env: DJANGO_VERSION="2.0" - python: "3.6" env: DJANGO_VERSION="1.5" - python: "3.6" env: DJANGO_VERSION="1.6" - python: "3.6" env: DJANGO_VERSION="1.7" - python: "2.7" env: DJANGO_VERSION="2.0" before_deploy: - pip install wheel - python setup.py sdist bdist_wheel deploy: provider: pypi user: bryanhelmig password: secure: amY+WgU7S4RD/8S4rDz6/Gso1bucyqWWdCfG5RXHxD1mBcOIBjfiVmDkbiOxODany3KS5Hmo3mvXjOlpgmhuxw2iWdG1o059pMh8PH7I2WwHTliUSGwIshFIUmAivrh1mq9qUsHfsGpPow3AaxFB7G/FnrAQjedTTGYfN5ZnI/k= distributions: "sdist bdist_wheel" on: tags: true repo: zapier/django-rest-hooks django-rest-hooks-1.6.0/AUTHORS.md000066400000000000000000000007231346655455400165460ustar00rootroot00000000000000Django REST Hooks is written and maintained by Zapier and various contributors: ## Development Lead - Bryan Helmig ## Patches and Suggestions - [Bryan Helmig](https://github.com/bryanhelmig) - [Arnaud Limbourg](https://github.com/arnaudlimbourg) - [tdruez](https://github.com/tdruez) - [Maina Nick](https://github.com/mainanick) - Jonathan Moss - [Erik Wickstrom](https://github.com/erikcw) - [Yaroslav Klyuyev](https://github.com/imposeren) django-rest-hooks-1.6.0/LICENSE.md000066400000000000000000000013511346655455400165010ustar00rootroot00000000000000## ISC License Copyright (c) 2016 Zapier Inc. Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. django-rest-hooks-1.6.0/README.md000066400000000000000000000366431346655455400163700ustar00rootroot00000000000000[![Travis CI Build](https://img.shields.io/travis/zapier/django-rest-hooks/master.svg)](https://travis-ci.org/zapier/django-rest-hooks) [![PyPI Download](https://img.shields.io/pypi/v/django-rest-hooks.svg)](https://pypi.python.org/pypi/django-rest-hooks) [![PyPI Status](https://img.shields.io/pypi/status/django-rest-hooks.svg)](https://pypi.python.org/pypi/django-rest-hooks) ## What are Django REST Hooks? REST Hooks are fancier versions of webhooks. Traditional webhooks are usually managed manually by the user, but REST Hooks are not! They encourage RESTful access to the hooks (or subscriptions) themselves. Add one, two or 15 hooks for any combination of event and URLs, then get notificatied in real-time by our bundled threaded callback mechanism. The best part is: by reusing Django's great signals framework, this library is dead simple. Here's how to get started: 1. Add `'rest_hooks'` to installed apps in settings.py. 2. Define your `HOOK_EVENTS` in settings.py. 3. Start sending hooks! Using our **built-in actions**, zero work is required to support *any* basic `created`, `updated`, and `deleted` actions across any Django model. We also allow for **custom actions** (IE: beyond **C**R**UD**) to be simply defined and triggered for any model, as well as truly custom events that let you send arbitrary payloads. By default, this library will just POST Django's JSON serialization of a model, but you can alternatively provide a `serialize_hook` method to customize payloads. *Please note:* this package does not implement any UI/API code, it only provides a handy framework or reference implementation for which to build upon. If you want to make a Django form or API resource, you'll need to do that yourself (though we've provided some example bits of code below). ### Changelog #### Version 1.6.0: Improvements: * Default handler of `raw_hook_event` uses the same logic as other handlers (see "Backwards incompatible changes" for details). * Lookup of event_name by model+action_name now has a complexity of `O(1)` instead of `O(len(settings.HOOK_EVENTS))` * `HOOK_CUSTOM_MODEL` is now similar to `AUTH_USER_MODEL`: must be of the form `app_label.model_name` (for django 1.7+). If old value is of the form `app_label.models.model_name` then it's automatically adapted. * `rest_hooks.models.Hook` is now really "swappable", so table creation is skipped if you have different `settings.HOOK_CUSTOM_MODEL` * `rest_hooks.models.AbstractHook.deliver_hook` now accepts a callable as `payload_override` argument (must accept 2 arguments: hook, instance). This was added to support old behavior of `raw_custom_event`. Fixes: * HookAdmin.form now honors `settings.HOOK_CUSTOM_MODEL` * event_name determined from action+model is now consistent between runs (see "Backwards incompatible changes") Backwards incompatible changes: * Dropped support for django 1.4 * Custom `HOOK_FINDER`-s should accept and handle new argument `payload_override`. Built-in finder `rest_hooks.utls.find_and_fire_hook` already does this. * If several event names in `settings.HOOK_EVENTS` share the same `'app_label.model.action'` (including `'app_label.model.action+'`) then `django.core.exceptions.ImproperlyConfigured` is raised * Receiver of `raw_hook_event` now uses the same logic as receivers of other signals: checks event_name against settings.HOOK_EVENTS, verifies model (if instance is passed), uses `HOOK_FINDER`. Old behaviour can be achieved by using `trust_event_name=True`, or `instance=None` to fire a signal. * If you have `settings.HOOK_CUSTOM_MODEL` of the form different than `app_label.models.model_name` or `app_label.model_name`, then it must be changed to `app_label.model_name`. ### Development Running the tests for Django REST Hooks is very easy, just: ``` git clone https://github.com/zapier/django-rest-hooks && cd django-rest-hooks ``` Next, you'll want to make a virtual environment (we recommend using virtualenvwrapper but you could skip this we suppose) and then install dependencies: ``` mkvirtualenv django-rest-hooks pip install -r devrequirements.txt ``` Now you can run the tests! ``` python runtests.py ``` ### Requirements * Python 2 or 3 (tested on 2.7, 3.3, 3.4, 3.6) * Django 1.5+ (tested on 1.5, 1.6, 1.7, 1.8, 1.9, 1.10, 1.11, 2.0) ### Installing & Configuring We recommend pip to install Django REST Hooks: ``` pip install django-rest-hooks ``` Next, you'll need to add `rest_hooks` to `INSTALLED_APPS` and configure your `HOOK_EVENTS` setting: ```python ### settings.py ### INSTALLED_APPS = ( # other apps here... 'rest_hooks', ) HOOK_EVENTS = { # 'any.event.name': 'App.Model.Action' (created/updated/deleted) 'book.added': 'bookstore.Book.created', 'book.changed': 'bookstore.Book.updated+', 'book.removed': 'bookstore.Book.deleted', # and custom events, no extra meta data needed 'book.read': 'bookstore.Book.read', 'user.logged_in': None } ### bookstore/models.py ### class Book(models.Model): # NOTE: it is important to have a user property # as we use it to help find and trigger each Hook # which is specific to users. If you want a Hook to # be triggered for all users, add '+' to built-in Hooks # or pass user_override=False for custom_hook events user = models.ForeignKey('auth.User', on_delete=models.CASCADE) # maybe user is off a related object, so try... # user = property(lambda self: self.intermediary.user) title = models.CharField(max_length=128) pages = models.PositiveIntegerField() fiction = models.BooleanField() # ... other fields here ... def serialize_hook(self, hook): # optional, there are serialization defaults # we recommend always sending the Hook # metadata along for the ride as well return { 'hook': hook.dict(), 'data': { 'id': self.id, 'title': self.title, 'pages': self.pages, 'fiction': self.fiction, # ... other fields here ... } } def mark_as_read(self): # models can also have custom defined events from rest_hooks.signals import hook_event hook_event.send( sender=self.__class__, action='read', instance=self # the Book object ) ``` For the simplest experience, you'll just piggyback off the standard ORM which will handle the basic `created`, `updated` and `deleted` signals & events: ```python >>> from django.contrib.auth.models import User >>> from rest_hooks.models import Hook >>> jrrtolkien = User.objects.create(username='jrrtolkien') >>> hook = Hook(user=jrrtolkien, event='book.added', target='http://example.com/target.php') >>> hook.save() # creates the hook and stores it for later... >>> from bookstore.models import Book >>> book = Book(user=jrrtolkien, title='The Two Towers', pages=327, fiction=True) >>> book.save() # fires off 'bookstore.Book.created' hook automatically ... ``` > NOTE: If you try to register an invalid event hook (not listed on HOOK_EVENTS in settings.py) you will get a **ValidationError**. Now that the book has been created, `http://example.com/target.php` will get: ``` POST http://example.com/target.php \ -H Content-Type: application/json \ -d '{"hook": { "id": 123, "event": "book.added", "target": "http://example.com/target.php"}, "data": { "title": "The Two Towers", "pages": 327, "fiction": true}}' ``` You can continue the example, triggering two more hooks in a similar method. However, since we have no hooks set up for `'book.changed'` or `'book.removed'`, they wouldn't get triggered anyways. ```python ... >>> book.title += ': Deluxe Edition' >>> book.pages = 352 >>> book.save() # would fire off 'bookstore.Book.updated' hook automatically >>> book.delete() # would fire off 'bookstore.Book.deleted' hook automatically ``` You can also fire custom events with an arbitrary payload: ```python from rest_hooks.signals import raw_hook_event user = User.objects.get(id=123) raw_hook_event.send( sender=None, event_name='user.logged_in', payload={ 'username': user.username, 'email': user.email, 'when': datetime.datetime.now().isoformat() }, user=user # required: used to filter Hooks ) ``` ### How does it work? Django has a stellar [signals framework](https://docs.djangoproject.com/en/dev/topics/signals/), all REST Hooks does is register to receive all `post_save` (created/updated) and `post_delete` (deleted) signals. It then filters them down by: 1. Which `App.Model.Action` actually have an event registered in `settings.HOOK_EVENTS`. 2. After it verifies that a matching event exists, it searches for matching Hooks via the ORM. 3. Any Hooks that are found for the User/event combination get sent a payload via POST. ### How would you interact with it in the real world? **Let's imagine for a second that you've plugged REST Hooks into your API**. One could definitely provide a user interface to create hooks themselves via a standard browser & HTML based CRUD interface, but the real magic is when the Hook resource is part of an API. The basic target functionality is: ```shell POST http://your-app.com/api/hooks?username=me&api_key=abcdef \ -H Content-Type: application/json \ -d '{"target": "http://example.com/target.php", "event": "book.added"}' ``` Now, whenever a Book is created (either via an ORM, a Django form, admin, etc...), `http://example.com/target.php` will get: ```shell POST http://example.com/target.php \ -H Content-Type: application/json \ -d '{"hook": { "id": 123, "event": "book.added", "target": "http://example.com/target.php"}, "data": { "title": "Structure and Interpretation of Computer Programs", "pages": 657, "fiction": false}}' ``` *It is important to note that REST Hooks will handle all of this hook callback logic for you automatically.* But you can stop it anytime you like with a simple: ``` DELETE http://your-app.com/api/hooks/123?username=me&api_key=abcdef ``` If you already have a REST API, this should be relatively straightforward, but if not, Tastypie is a great choice. Some reference [Tastypie](http://tastypieapi.org/) or [Django REST framework](http://django-rest-framework.org/): + REST Hook code is below. #### Tastypie ```python ### resources.py ### from tastypie.resources import ModelResource from tastypie.authentication import ApiKeyAuthentication from tastypie.authorization import Authorization from rest_hooks.models import Hook class HookResource(ModelResource): def obj_create(self, bundle, request=None, **kwargs): return super(HookResource, self).obj_create(bundle, request, user=request.user) def apply_authorization_limits(self, request, object_list): return object_list.filter(user=request.user) class Meta: resource_name = 'hooks' queryset = Hook.objects.all() authentication = ApiKeyAuthentication() authorization = Authorization() allowed_methods = ['get', 'post', 'delete'] fields = ['event', 'target'] ### urls.py ### from tastypie.api import Api v1_api = Api(api_name='v1') v1_api.register(HookResource()) urlpatterns = patterns('', (r'^api/', include(v1_api.urls)), ) ``` #### Django REST framework (3.+) ```python ### serializers.py ### from django.conf import settings from rest_framework import serializers, exceptions from rest_hooks.models import Hook class HookSerializer(serializers.ModelSerializer): def validate_event(self, event): if event not in settings.HOOK_EVENTS: err_msg = "Unexpected event {}".format(event) raise exceptions.ValidationError(detail=err_msg, code=400) return event class Meta: model = Hook fields = '__all__' read_only_fields = ('user',) ### views.py ### from rest_framework import viewsets from rest_hooks.models import Hook from .serializers import HookSerializer class HookViewSet(viewsets.ModelViewSet): """ Retrieve, create, update or destroy webhooks. """ queryset = Hook.objects.all() model = Hook serializer_class = HookSerializer def perform_create(self, serializer): serializer.save(user=self.request.user) ### urls.py ### from rest_framework import routers from . import views router = routers.SimpleRouter(trailing_slash=False) router.register(r'webhooks', views.HookViewSet, 'webhook') urlpatterns = router.urls ``` ### Some gotchas: Instead of doing blocking HTTP requests inside of signals, we've opted for a simple Threading pool that should handle the majority of use cases. However, if you use Celery, we'd *really* recommend using a simple task to handle this instead of threads. A quick example: ```python ### settings.py ### HOOK_DELIVERER = 'path.to.tasks.deliver_hook_wrapper' ### tasks.py ### from celery.task import Task import json import requests class DeliverHook(Task): max_retries = 5 def run(self, target, payload, instance_id=None, hook_id=None, **kwargs): """ target: the url to receive the payload. payload: a python primitive data structure instance_id: a possibly None "trigger" instance ID hook_id: the ID of defining Hook object """ try: response = requests.post( url=target, data=json.dumps(payload), headers={'Content-Type': 'application/json'} ) if response.status_code >= 500: response.raise_for_status() except requests.ConnectionError: delay_in_seconds = 2 ** self.request.retries self.retry(countdown=delay_in_seconds) def deliver_hook_wrapper(target, payload, instance, hook): # instance is None if using custom event, not built-in if instance is not None: instance_id = instance.id else: instance_id = None # pass ID's not objects because using pickle for objects is a bad thing kwargs = dict(target=target, payload=payload, instance_id=instance_id, hook_id=hook.id) DeliverHook.apply_async(kwargs=kwargs) ``` We also don't handle retries or cleanup. Generally, if you get a `410` or a bunch of `4xx` or `5xx`, you should delete the Hook and let the user know. ### Extend the Hook model: The default `Hook` model fields can be extended using the `AbstractHook` model. For example, to add a `is_active` field on your hooks: ```python ### settings.py ### HOOK_CUSTOM_MODEL = 'path.to.models.CustomHook' ### models.py ### from django.db import models from rest_hooks.models import AbstractHook class CustomHook(AbstractHook): is_active = models.BooleanField(default=True) ``` The extended `CustomHook` model can be combined with a the `HOOK_FINDER` setting for advanced QuerySet filtering. ```python ### settings.py ### HOOK_FINDER = 'path.to.find_and_fire_hook' ### utils.py ### from .models import CustomHook def find_and_fire_hook(event_name, instance, **kwargs): filters = { 'event': event_name, 'is_active': True, } hooks = CustomHook.objects.filter(**filters) for hook in hooks: hook.deliver_hook(instance) ``` django-rest-hooks-1.6.0/devrequirements.txt000066400000000000000000000000341346655455400210550ustar00rootroot00000000000000requests==1.2.3 mock==1.0.1 django-rest-hooks-1.6.0/devrequirements_1.10.txt000066400000000000000000000001111346655455400215100ustar00rootroot00000000000000-r devrequirements.txt django-contrib-comments>=1.7.2 Django>=1.10,<1.11 django-rest-hooks-1.6.0/devrequirements_1.11.txt000066400000000000000000000001111346655455400215110ustar00rootroot00000000000000-r devrequirements.txt django-contrib-comments>=1.8.0 Django>=1.11,<1.12 django-rest-hooks-1.6.0/devrequirements_1.5.txt000066400000000000000000000000501346655455400214360ustar00rootroot00000000000000-r devrequirements.txt Django>=1.5,<1.6 django-rest-hooks-1.6.0/devrequirements_1.6.txt000066400000000000000000000000501346655455400214370ustar00rootroot00000000000000-r devrequirements.txt Django>=1.6,<1.7 django-rest-hooks-1.6.0/devrequirements_1.7.txt000066400000000000000000000000501346655455400214400ustar00rootroot00000000000000-r devrequirements.txt Django>=1.7,<1.8 django-rest-hooks-1.6.0/devrequirements_1.8.txt000066400000000000000000000001161346655455400214440ustar00rootroot00000000000000-r devrequirements.txt django-contrib-comments>=1.6.1,<1.7.0 Django>=1.8,<1.9 django-rest-hooks-1.6.0/devrequirements_1.9.txt000066400000000000000000000001171346655455400214460ustar00rootroot00000000000000-r devrequirements.txt django-contrib-comments>=1.6.2,<1.7.0 Django>=1.9,<1.10 django-rest-hooks-1.6.0/devrequirements_2.0.txt000066400000000000000000000001071346655455400214350ustar00rootroot00000000000000-r devrequirements.txt django-contrib-comments>=1.8.0 Django>=2.0,<2.1 django-rest-hooks-1.6.0/rest_hooks/000077500000000000000000000000001346655455400172555ustar00rootroot00000000000000django-rest-hooks-1.6.0/rest_hooks/__init__.py000066400000000000000000000000241346655455400213620ustar00rootroot00000000000000VERSION = (1, 6, 0) django-rest-hooks-1.6.0/rest_hooks/admin.py000066400000000000000000000020251346655455400207160ustar00rootroot00000000000000from django.contrib import admin from django.conf import settings from django import forms from rest_hooks.utils import get_hook_model if getattr(settings, 'HOOK_EVENTS', None) is None: raise Exception("You need to define settings.HOOK_EVENTS!") HookModel = get_hook_model() class HookForm(forms.ModelForm): """ Model form to handle registered events, asuring only events declared on HOOK_EVENTS settings can be registered. """ class Meta: model = HookModel fields = ['user', 'target', 'event'] def __init__(self, *args, **kwargs): super(HookForm, self).__init__(*args, **kwargs) self.fields['event'] = forms.ChoiceField(choices=self.get_admin_events()) @classmethod def get_admin_events(cls): return [(x, x) for x in getattr(settings, 'HOOK_EVENTS', None).keys()] class HookAdmin(admin.ModelAdmin): list_display = [f.name for f in HookModel._meta.fields] raw_id_fields = ['user', ] form = HookForm admin.site.register(HookModel, HookAdmin) django-rest-hooks-1.6.0/rest_hooks/client.py000066400000000000000000000033461346655455400211130ustar00rootroot00000000000000import threading import collections import requests class FlushThread(threading.Thread): def __init__(self, client): threading.Thread.__init__(self) self.client = client def run(self): self.client.sync_flush() class Client(object): """ Manages a simple pool of threads to flush the queue of requests. """ def __init__(self, num_threads=3): self.queue = collections.deque() self.flush_lock = threading.Lock() self.num_threads = num_threads self.flush_threads = [FlushThread(self) for _ in range(self.num_threads)] self.total_sent = 0 def enqueue(self, method, *args, **kwargs): self.queue.append((method, args, kwargs)) self.refresh_threads() def get(self, *args, **kwargs): self.enqueue('get', *args, **kwargs) def post(self, *args, **kwargs): self.enqueue('post', *args, **kwargs) def put(self, *args, **kwargs): self.enqueue('put', *args, **kwargs) def delete(self, *args, **kwargs): self.enqueue('delete', *args, **kwargs) def refresh_threads(self): with self.flush_lock: # refresh if there are jobs to do and no threads are alive if len(self.queue) > 0: to_refresh = [index for index, thread in enumerate(self.flush_threads) if not thread.is_alive()] for index in to_refresh: self.flush_threads[index] = FlushThread(self) self.flush_threads[index].start() def sync_flush(self): session = requests.Session() while self.queue: method, args, kwargs = self.queue.pop() getattr(session, method)(*args, **kwargs) self.total_sent += 1 django-rest-hooks-1.6.0/rest_hooks/migrations/000077500000000000000000000000001346655455400214315ustar00rootroot00000000000000django-rest-hooks-1.6.0/rest_hooks/migrations/0001_initial.py000066400000000000000000000021611346655455400240740ustar00rootroot00000000000000# -*- coding: utf-8 -*- from __future__ import unicode_literals import django.db.models.deletion from django.db import models, migrations from django.conf import settings class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( name='Hook', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('created', models.DateTimeField(auto_now_add=True)), ('updated', models.DateTimeField(auto_now=True)), ('event', models.CharField(max_length=64, verbose_name='Event', db_index=True)), ('target', models.URLField(max_length=255, verbose_name='Target URL')), ('user', models.ForeignKey(related_name='hooks', to=settings.AUTH_USER_MODEL, on_delete=django.db.models.deletion.CASCADE)), ], options={ 'swappable': 'HOOK_CUSTOM_MODEL', }, bases=(models.Model,), ), ] django-rest-hooks-1.6.0/rest_hooks/migrations/0002_swappable_hook_model.py000066400000000000000000000005511346655455400266230ustar00rootroot00000000000000# -*- coding: utf-8 -*- from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ('rest_hooks', '0001_initial'), ] operations = [ migrations.AlterModelOptions( name='Hook', options={ 'swappable': 'HOOK_CUSTOM_MODEL', }, ), ] django-rest-hooks-1.6.0/rest_hooks/migrations/__init__.py000066400000000000000000000000001346655455400235300ustar00rootroot00000000000000django-rest-hooks-1.6.0/rest_hooks/models.py000066400000000000000000000173451346655455400211240ustar00rootroot00000000000000from collections import OrderedDict import requests import django from django.conf import settings from django.core import serializers from django.core.exceptions import ValidationError, ImproperlyConfigured from django.core.serializers.json import DjangoJSONEncoder from django.db import models from django.db.models.signals import post_save, post_delete from django.test.signals import setting_changed from django.dispatch import receiver try: # Django <= 1.6 backwards compatibility from django.utils import simplejson as json except ImportError: # Django >= 1.7 import json from rest_hooks.signals import hook_event, raw_hook_event, hook_sent_event from rest_hooks.utils import distill_model_event, get_hook_model, get_module, find_and_fire_hook if getattr(settings, 'HOOK_CUSTOM_MODEL', None) is None: settings.HOOK_CUSTOM_MODEL = 'rest_hooks.Hook' HOOK_EVENTS = getattr(settings, 'HOOK_EVENTS', None) if HOOK_EVENTS is None: raise Exception('You need to define settings.HOOK_EVENTS!') _HOOK_EVENT_ACTIONS_CONFIG = None def get_event_actions_config(): global _HOOK_EVENT_ACTIONS_CONFIG if _HOOK_EVENT_ACTIONS_CONFIG is None: _HOOK_EVENT_ACTIONS_CONFIG = {} for event_name, auto in HOOK_EVENTS.items(): if not auto: continue model_label, action = auto.rsplit('.', 1) action_parts = action.rsplit('+', 1) action = action_parts[0] ignore_user_override = False if len(action_parts) == 2: ignore_user_override = True model_config = _HOOK_EVENT_ACTIONS_CONFIG.setdefault(model_label, {}) if action in model_config: raise ImproperlyConfigured( "settings.HOOK_EVENTS have a dublicate {action} for model " "{model_label}".format(action=action, model_label=model_label) ) model_config[action] = (event_name, ignore_user_override,) return _HOOK_EVENT_ACTIONS_CONFIG if getattr(settings, 'HOOK_THREADING', True): from rest_hooks.client import Client client = Client() else: client = requests.Session() AUTH_USER_MODEL = getattr(settings, 'AUTH_USER_MODEL', 'auth.User') class AbstractHook(models.Model): """ Stores a representation of a Hook. """ created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) user = models.ForeignKey(AUTH_USER_MODEL, related_name='%(class)ss', on_delete=models.CASCADE) event = models.CharField('Event', max_length=64, db_index=True) target = models.URLField('Target URL', max_length=255) class Meta: abstract = True def clean(self): """ Validation for events. """ if self.event not in HOOK_EVENTS.keys(): raise ValidationError( "Invalid hook event {evt}.".format(evt=self.event) ) def dict(self): return { 'id': self.id, 'event': self.event, 'target': self.target } def serialize_hook(self, instance): """ Serialize the object down to Python primitives. By default it uses Django's built in serializer. """ if getattr(instance, 'serialize_hook', None) and callable(instance.serialize_hook): return instance.serialize_hook(hook=self) if getattr(settings, 'HOOK_SERIALIZER', None): serializer = get_module(settings.HOOK_SERIALIZER) return serializer(instance, hook=self) # if no user defined serializers, fallback to the django builtin! data = serializers.serialize('python', [instance])[0] for k, v in data.items(): if isinstance(v, OrderedDict): data[k] = dict(v) if isinstance(data, OrderedDict): data = dict(data) return { 'hook': self.dict(), 'data': data, } def deliver_hook(self, instance, payload_override=None): """ Deliver the payload to the target URL. By default it serializes to JSON and POSTs. Args: instance: instance that triggered event. payload_override: JSON-serializable object or callable that will return such object. If callable is used it should accept 2 arguments: `hook` and `instance`. """ if payload_override is None: payload = self.serialize_hook(instance) else: payload = payload_override if callable(payload): payload = payload(self, instance) if getattr(settings, 'HOOK_DELIVERER', None): deliverer = get_module(settings.HOOK_DELIVERER) deliverer(self.target, payload, instance=instance, hook=self) else: client.post( url=self.target, data=json.dumps(payload, cls=DjangoJSONEncoder), headers={'Content-Type': 'application/json'} ) hook_sent_event.send_robust(sender=self.__class__, payload=payload, instance=instance, hook=self) return None def __unicode__(self): return u'{} => {}'.format(self.event, self.target) class Hook(AbstractHook): if django.VERSION >= (1, 7): class Meta(AbstractHook.Meta): swappable = 'HOOK_CUSTOM_MODEL' ############## ### EVENTS ### ############## def get_model_label(instance): if instance is None: return None opts = instance._meta.concrete_model._meta try: return opts.label except AttributeError: return '.'.join([opts.app_label, opts.object_name]) @receiver(post_save, dispatch_uid='instance-saved-hook') def model_saved(sender, instance, created, raw, using, **kwargs): """ Automatically triggers "created" and "updated" actions. """ model_label = get_model_label(instance) action = 'created' if created else 'updated' distill_model_event(instance, model_label, action) @receiver(post_delete, dispatch_uid='instance-deleted-hook') def model_deleted(sender, instance, using, **kwargs): """ Automatically triggers "deleted" actions. """ model_label = get_model_label(instance) distill_model_event(instance, model_label, 'deleted') @receiver(hook_event, dispatch_uid='instance-custom-hook') def custom_action(sender, action, instance, user=None, **kwargs): """ Manually trigger a custom action (or even a standard action). """ model_label = get_model_label(instance) distill_model_event(instance, model_label, action, user_override=user) @receiver(raw_hook_event, dispatch_uid='raw-custom-hook') def raw_custom_event( sender, event_name, payload, user, send_hook_meta=True, instance=None, trust_event_name=False, **kwargs ): """ Give a full payload """ model_label = get_model_label(instance) new_payload = payload if send_hook_meta: new_payload = lambda hook, instance: { 'hook': hook.dict(), 'data': payload } distill_model_event( instance, model_label, None, user_override=user, event_name=event_name, trust_event_name=trust_event_name, payload_override=new_payload, ) @receiver(setting_changed) def handle_hook_events_change(sender, setting, *args, **kwargs): global _HOOK_EVENT_ACTIONS_CONFIG global HOOK_EVENTS if setting == 'HOOK_EVENTS': _HOOK_EVENT_ACTIONS_CONFIG = None HOOK_EVENTS = settings.HOOK_EVENTS django-rest-hooks-1.6.0/rest_hooks/signals.py000066400000000000000000000003631346655455400212710ustar00rootroot00000000000000from django.dispatch import Signal hook_event = Signal(providing_args=['action', 'instance']) raw_hook_event = Signal(providing_args=['event_name', 'payload', 'user']) hook_sent_event = Signal(providing_args=['payload', 'instance', 'hook']) django-rest-hooks-1.6.0/rest_hooks/south_migrations/000077500000000000000000000000001346655455400226535ustar00rootroot00000000000000django-rest-hooks-1.6.0/rest_hooks/south_migrations/0001_initial.py000066400000000000000000000115051346655455400253200ustar00rootroot00000000000000# -*- coding: utf-8 -*- from south.utils import datetime_utils as datetime from south.db import db from south.v2 import SchemaMigration from django.db import models class Migration(SchemaMigration): def forwards(self, orm): # Adding model 'Hook' db.create_table('rest_hooks_hook', ( ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), ('updated', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)), ('user', self.gf('django.db.models.fields.related.ForeignKey')(related_name='hooks', to=orm['auth.User'])), ('event', self.gf('django.db.models.fields.CharField')(max_length=64, db_index=True)), ('target', self.gf('django.db.models.fields.URLField')(max_length=255)), )) db.send_create_signal('rest_hooks', ['Hook']) def backwards(self, orm): # Deleting model 'Hook' db.delete_table('rest_hooks_hook') models = { 'auth.group': { 'Meta': {'object_name': 'Group'}, 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) }, 'auth.permission': { 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) }, 'auth.user': { 'Meta': {'object_name': 'User'}, 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) }, 'contenttypes.contenttype': { 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) }, 'rest_hooks.hook': { 'Meta': {'object_name': 'Hook'}, 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), 'event': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), 'target': ('django.db.models.fields.URLField', [], {'max_length': '255'}), 'updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'hooks'", 'to': "orm['auth.User']"}) } } complete_apps = ['rest_hooks']django-rest-hooks-1.6.0/rest_hooks/south_migrations/__init__.py000066400000000000000000000000001346655455400247520ustar00rootroot00000000000000django-rest-hooks-1.6.0/rest_hooks/tasks.py000066400000000000000000000021511346655455400207530ustar00rootroot00000000000000import requests import json from celery.task import Task from django.core.serializers.json import DjangoJSONEncoder from rest_hooks.utils import get_hook_model class DeliverHook(Task): def run(self, target, payload, instance=None, hook_id=None, **kwargs): """ target: the url to receive the payload. payload: a python primitive data structure instance: a possibly null "trigger" instance hook: the defining Hook object (useful for removing) """ response = requests.post( url=target, data=json.dumps(payload, cls=DjangoJSONEncoder), headers={'Content-Type': 'application/json'} ) if response.status_code == 410 and hook_id: HookModel = get_hook_model() hook = HookModel.object.get(id=hook_id) hook.delete() # would be nice to log this, at least for a little while... def deliver_hook_wrapper(target, payload, instance=None, hook=None, **kwargs): if hook: kwargs['hook_id'] = hook.id return DeliverHook.delay(target, payload, **kwargs) django-rest-hooks-1.6.0/rest_hooks/tests.py000066400000000000000000000257421346655455400210030ustar00rootroot00000000000000import requests import time from mock import patch, MagicMock, ANY from datetime import datetime try: # Django <= 1.6 backwards compatibility from django.utils import simplejson as json except ImportError: # Django >= 1.7 import json from django.contrib.auth.models import User from django.contrib.sites.models import Site from django.test import TestCase from django.test.utils import override_settings try: from django.contrib.comments.models import Comment comments_app_label = 'comments' except ImportError: from django_comments.models import Comment comments_app_label = 'django_comments' from rest_hooks import models from rest_hooks import signals from rest_hooks.admin import HookForm Hook = models.Hook urlpatterns = [] HOOK_EVENTS_OVERRIDE = { 'comment.added': comments_app_label + '.Comment.created', 'comment.changed': comments_app_label + '.Comment.updated', 'comment.removed': comments_app_label + '.Comment.deleted', 'comment.moderated': comments_app_label + '.Comment.moderated', 'special.thing': None, } ALT_HOOK_EVENTS = dict(HOOK_EVENTS_OVERRIDE) ALT_HOOK_EVENTS['comment.moderated'] += '+' @override_settings(HOOK_EVENTS=HOOK_EVENTS_OVERRIDE, HOOK_DELIVERER=None) class RESTHooksTest(TestCase): """ This test Class uses real HTTP calls to a requestbin service, making it easy to check responses and endpoint history. """ ############# ### TOOLS ### ############# def setUp(self): self.client = requests # force non-async for test cases self.user = User.objects.create_user('bob', 'bob@example.com', 'password') self.site, created = Site.objects.get_or_create(domain='example.com', name='example.com') def make_hook(self, event, target): return Hook.objects.create( user=self.user, event=event, target=target ) ############# ### TESTS ### ############# @override_settings(HOOK_EVENTS=ALT_HOOK_EVENTS) def test_get_event_actions_config(self): self.assertEquals( models.get_event_actions_config(), { comments_app_label + '.Comment': { 'created': ('comment.added', False), 'updated': ('comment.changed', False), 'deleted': ('comment.removed', False), 'moderated': ('comment.moderated', True), }, } ) def test_no_user_property_fail(self): with self.assertRaises(Exception): models.find_and_fire_hook('some.fake.event', self.user) models.find_and_fire_hook('special.thing', self.user) def test_no_hook(self): comment = Comment.objects.create( site=self.site, content_object=self.user, user=self.user, comment='Hello world!' ) @patch('rest_hooks.models.client.post', autospec=True) def perform_create_request_cycle(self, method_mock): method_mock.return_value = None target = 'http://example.com/perform_create_request_cycle' hook = self.make_hook('comment.added', target) comment = Comment.objects.create( site=self.site, content_object=self.user, user=self.user, comment='Hello world!' ) # time.sleep(1) # should change a setting to turn off async return hook, comment, json.loads(method_mock.call_args_list[0][1]['data']) def test_simple_comment_hook(self): """ Uses the default serializer. """ hook, comment, payload = self.perform_create_request_cycle() self.assertEquals(hook.id, payload['hook']['id']) self.assertEquals('comment.added', payload['hook']['event']) self.assertEquals(hook.target, payload['hook']['target']) self.assertEquals(comment.id, payload['data']['pk']) self.assertEquals('Hello world!', payload['data']['fields']['comment']) self.assertEquals(comment.user.id, payload['data']['fields']['user']) def test_comment_hook_serializer_method(self): """ Use custom serialize_hook on the Comment model. """ def serialize_hook(comment, hook): return { 'hook': hook.dict(), 'data': { 'id': comment.id, 'comment': comment.comment, 'user': { 'username': comment.user.username, 'email': comment.user.email}}} Comment.serialize_hook = serialize_hook hook, comment, payload = self.perform_create_request_cycle() self.assertEquals(hook.id, payload['hook']['id']) self.assertEquals('comment.added', payload['hook']['event']) self.assertEquals(hook.target, payload['hook']['target']) self.assertEquals(comment.id, payload['data']['id']) self.assertEquals('Hello world!', payload['data']['comment']) self.assertEquals('bob', payload['data']['user']['username']) del Comment.serialize_hook @patch('rest_hooks.models.client.post') def test_full_cycle_comment_hook(self, method_mock): method_mock.return_value = None target = 'http://example.com/test_full_cycle_comment_hook' hooks = [self.make_hook(event, target) for event in ['comment.added', 'comment.changed', 'comment.removed']] # created comment = Comment.objects.create( site=self.site, content_object=self.user, user=self.user, comment='Hello world!' ) # time.sleep(0.5) # should change a setting to turn off async # updated comment.comment = 'Goodbye world...' comment.save() # time.sleep(0.5) # should change a setting to turn off async # deleted comment.delete() # time.sleep(0.5) # should change a setting to turn off async payloads = [json.loads(call[2]['data']) for call in method_mock.mock_calls] self.assertEquals('comment.added', payloads[0]['hook']['event']) self.assertEquals('comment.changed', payloads[1]['hook']['event']) self.assertEquals('comment.removed', payloads[2]['hook']['event']) self.assertEquals('Hello world!', payloads[0]['data']['fields']['comment']) self.assertEquals('Goodbye world...', payloads[1]['data']['fields']['comment']) self.assertEquals('Goodbye world...', payloads[2]['data']['fields']['comment']) @patch('rest_hooks.models.client.post') def test_custom_instance_hook(self, method_mock): from rest_hooks.signals import hook_event method_mock.return_value = None target = 'http://example.com/test_custom_instance_hook' hook = self.make_hook('comment.moderated', target) comment = Comment.objects.create( site=self.site, content_object=self.user, user=self.user, comment='Hello world!' ) hook_event.send( sender=comment.__class__, action='moderated', instance=comment ) # time.sleep(1) # should change a setting to turn off async payloads = [json.loads(call[2]['data']) for call in method_mock.mock_calls] self.assertEquals('comment.moderated', payloads[0]['hook']['event']) self.assertEquals('Hello world!', payloads[0]['data']['fields']['comment']) @patch('rest_hooks.models.client.post') def test_raw_custom_event(self, method_mock): from rest_hooks.signals import raw_hook_event method_mock.return_value = None target = 'http://example.com/test_raw_custom_event' hook = self.make_hook('special.thing', target) raw_hook_event.send( sender=None, event_name='special.thing', payload={ 'hello': 'world!' }, user=self.user ) # time.sleep(1) # should change a setting to turn off async payload = json.loads(method_mock.mock_calls[0][2]['data']) self.assertEquals('special.thing', payload['hook']['event']) self.assertEquals('world!', payload['data']['hello']) def test_timed_cycle(self): return # basically a debug test for thread pool bit target = 'http://requestbin.zapier.com/api/v1/bin/test_timed_cycle' hooks = [self.make_hook(event, target) for event in ['comment.added', 'comment.changed', 'comment.removed']] for n in range(4): early = datetime.now() # fires N * 3 http calls for x in range(10): comment = Comment.objects.create( site=self.site, content_object=self.user, user=self.user, comment='Hello world!' ) comment.comment = 'Goodbye world...' comment.save() comment.delete() total = datetime.now() - early print(total) while True: response = requests.get(target + '/view') sent = response.json if sent: print(len(sent), models.async_requests.total_sent) if models.async_requests.total_sent >= (30 * (n+1)): time.sleep(5) break time.sleep(1) requests.delete(target + '/view') # cleanup to be polite def test_signal_emitted_upon_success(self): wrapper = lambda *args, **kwargs: None mock_handler = MagicMock(wraps=wrapper) signals.hook_sent_event.connect(mock_handler, sender=Hook) hook, comment, payload = self.perform_create_request_cycle() payload['data']['fields']['submit_date'] = ANY mock_handler.assert_called_with(signal=ANY, sender=Hook, payload=payload, instance=comment, hook=hook) def test_valid_form(self): form_data = { 'user': self.user.id, 'target': "http://example.com", 'event': HookForm.get_admin_events()[0][0] } form = HookForm(data=form_data) self.assertTrue(form.is_valid()) def test_form_save(self): form_data = { 'user': self.user.id, 'target': "http://example.com", 'event': HookForm.get_admin_events()[0][0] } form = HookForm(data=form_data) self.assertTrue(form.is_valid()) instance = form.save() self.assertIsInstance(instance, Hook) def test_invalid_form(self): form = HookForm(data={}) self.assertFalse(form.is_valid()) @override_settings(HOOK_CUSTOM_MODEL='rest_hooks.models.Hook') def test_get_custom_hook_model(self): # Using the default Hook model just to exercise get_hook_model's # lookup machinery. from rest_hooks.utils import get_hook_model from rest_hooks.models import AbstractHook HookModel = get_hook_model() self.assertIs(HookModel, Hook) self.assertTrue(issubclass(HookModel, AbstractHook)) django-rest-hooks-1.6.0/rest_hooks/utils.py000066400000000000000000000134361346655455400207760ustar00rootroot00000000000000import django try: from django.apps import apps as django_apps except ImportError: django_apps = None from django.core.exceptions import ImproperlyConfigured from django.conf import settings if django.VERSION >= (2, 0,): get_model_kwargs = {'require_ready': False} else: get_model_kwargs = {} def get_module(path): """ A modified duplicate from Django's built in backend retriever. slugify = get_module('django.template.defaultfilters.slugify') """ try: from importlib import import_module except ImportError as e: from django.utils.importlib import import_module try: mod_name, func_name = path.rsplit('.', 1) mod = import_module(mod_name) except ImportError as e: raise ImportError( 'Error importing alert function {0}: "{1}"'.format(mod_name, e)) try: func = getattr(mod, func_name) except AttributeError: raise ImportError( ('Module "{0}" does not define a "{1}" function' ).format(mod_name, func_name)) return func def get_hook_model(): """ Returns the Custom Hook model if defined in settings, otherwise the default Hook model. """ model_label = getattr(settings, 'HOOK_CUSTOM_MODEL', None) if django_apps: model_label = (model_label or 'rest_hooks.Hook').replace('.models.', '.') try: return django_apps.get_model(model_label, **get_model_kwargs) except ValueError: raise ImproperlyConfigured("HOOK_CUSTOM_MODEL must be of the form 'app_label.model_name'") except LookupError: raise ImproperlyConfigured( "HOOK_CUSTOM_MODEL refers to model '%s' that has not been installed" % model_label ) else: if model_label in (None, 'rest_hooks.Hook'): from rest_hooks.models import Hook HookModel = Hook else: try: HookModel = get_module(settings.HOOK_CUSTOM_MODEL) except ImportError: raise ImproperlyConfigured( "HOOK_CUSTOM_MODEL refers to model '%s' that cannot be imported" % model_label ) return HookModel def find_and_fire_hook(event_name, instance, user_override=None, payload_override=None): """ Look up Hooks that apply """ try: from django.contrib.auth import get_user_model User = get_user_model() except ImportError: from django.contrib.auth.models import User from rest_hooks.models import HOOK_EVENTS if event_name not in HOOK_EVENTS.keys(): raise Exception( '"{}" does not exist in `settings.HOOK_EVENTS`.'.format(event_name) ) filters = {'event': event_name} # Ignore the user if the user_override is False if user_override is not False: if user_override: filters['user'] = user_override elif hasattr(instance, 'user'): filters['user'] = instance.user elif isinstance(instance, User): filters['user'] = instance else: raise Exception( '{} has no `user` property. REST Hooks needs this.'.format(repr(instance)) ) # NOTE: This is probably up for discussion, but I think, in this # case, instead of raising an error, we should fire the hook for # all users/accounts it is subscribed to. That would be a genuine # usecase rather than erroring because no user is associated with # this event. HookModel = get_hook_model() hooks = HookModel.objects.filter(**filters) for hook in hooks: hook.deliver_hook(instance, payload_override=payload_override) def distill_model_event( instance, model=False, action=False, user_override=None, event_name=False, trust_event_name=False, payload_override=None, ): """ Take `event_name` or determine it using action and model from settings.HOOK_EVENTS, and let hooks fly. if `event_name` is passed together with `model` or `action`, then they should be the same as in settings or `trust_event_name` should be `True` If event_name is not found or is invalidated, then just quit silently. If payload_override is passed, then it will be passed into HookModel.deliver_hook """ from rest_hooks.models import get_event_actions_config, HOOK_EVENTS if event_name is False and (model is False or action is False): raise TypeError( 'distill_model_event() requires either `event_name` argument or ' 'both `model` and `action` arguments.' ) if event_name: if trust_event_name: pass elif event_name in HOOK_EVENTS: auto = HOOK_EVENTS[event_name] if auto: allowed_model, allowed_action = auto.rsplit('.', 1) allowed_action_parts = allowed_action.rsplit('+', 1) allowed_action = allowed_action_parts[0] model = model or allowed_model action = action or allowed_action if not (model == allowed_model and action == allowed_action): event_name = None if len(allowed_action_parts) == 2: user_override = False else: event_actions_config = get_event_actions_config() event_name, ignore_user_override = event_actions_config.get(model, {}).get(action, (None, False)) if ignore_user_override: user_override = False if event_name: if getattr(settings, 'HOOK_FINDER', None): finder = get_module(settings.HOOK_FINDER) else: finder = find_and_fire_hook finder(event_name, instance, user_override=user_override, payload_override=payload_override) django-rest-hooks-1.6.0/runtests.py000077500000000000000000000021321346655455400173370ustar00rootroot00000000000000#!/usr/bin/env python import sys import django from django.conf import settings APP_NAME = 'rest_hooks' if django.VERSION < (1, 8): comments = 'django.contrib.comments' else: comments = 'django_comments' settings.configure( DEBUG=True, DATABASES={ 'default': { 'ENGINE': 'django.db.backends.sqlite3', } }, USE_TZ=True, ROOT_URLCONF='{0}.tests'.format(APP_NAME), MIDDLEWARE_CLASSES=( 'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', ), SITE_ID=1, HOOK_EVENTS={}, HOOK_THREADING=False, INSTALLED_APPS=( 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.admin', 'django.contrib.sites', comments, APP_NAME, ), ) from django.test.utils import get_runner if hasattr(django, 'setup'): django.setup() TestRunner = get_runner(settings) test_runner = TestRunner() failures = test_runner.run_tests([APP_NAME]) if failures: sys.exit(failures) django-rest-hooks-1.6.0/setup.cfg000066400000000000000000000000341346655455400167130ustar00rootroot00000000000000[bdist_wheel] universal = 1 django-rest-hooks-1.6.0/setup.py000066400000000000000000000023101346655455400166030ustar00rootroot00000000000000try: from setuptools import setup except ImportError: from distutils.core import setup # if setuptools breaks # Dynamically calculate the version version_tuple = __import__('rest_hooks').VERSION version = '.'.join([str(v) for v in version_tuple]) setup( name = 'django-rest-hooks', description = 'A powerful mechanism for sending real time API notifications via a new subscription model.', version = version, author = 'Bryan Helmig', author_email = 'bryan@zapier.com', url = 'http://github.com/zapier/django-rest-hooks', install_requires=['Django>=1.5', 'requests'], packages=['rest_hooks'], package_data={ 'rest_hooks': [ 'migrations/*.py', 'south_migrations/*.py' ] }, classifiers = [ 'Development Status :: 5 - Production/Stable', 'Environment :: Web Environment', 'Framework :: Django', 'Intended Audience :: Developers', 'License :: OSI Approved :: BSD License', 'Operating System :: OS Independent', 'Programming Language :: Python', 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 3', 'Topic :: Utilities', ], )