Merge pull request #209 from taigaio/us/1678/webhooks

US #1678: Add webhooks to the backend
remotes/origin/enhancement/email-actions
Alejandro 2015-01-14 16:28:42 +01:00
commit 2fba748487
20 changed files with 1044 additions and 11 deletions

View File

@ -197,6 +197,7 @@ INSTALLED_APPS = [
"taiga.hooks.github", "taiga.hooks.github",
"taiga.hooks.gitlab", "taiga.hooks.gitlab",
"taiga.hooks.bitbucket", "taiga.hooks.bitbucket",
"taiga.webhooks",
"rest_framework", "rest_framework",
"djmail", "djmail",
@ -367,6 +368,7 @@ GITLAB_VALID_ORIGIN_IPS = []
EXPORTS_TTL = 60 * 60 * 24 # 24 hours EXPORTS_TTL = 60 * 60 * 24 # 24 hours
CELERY_ENABLED = False CELERY_ENABLED = False
WEBHOOKS_ENABLED = False
# NOTE: DON'T INSERT MORE SETTINGS AFTER THIS LINE # NOTE: DON'T INSERT MORE SETTINGS AFTER THIS LINE
TEST_RUNNER="django.test.runner.DiscoverRunner" TEST_RUNNER="django.test.runner.DiscoverRunner"

View File

@ -19,6 +19,7 @@ from .development import *
SKIP_SOUTH_TESTS = True SKIP_SOUTH_TESTS = True
SOUTH_TESTS_MIGRATE = False SOUTH_TESTS_MIGRATE = False
CELERY_ALWAYS_EAGER = True CELERY_ALWAYS_EAGER = True
CELERY_ENABLED = False
MEDIA_ROOT = "/tmp" MEDIA_ROOT = "/tmp"

View File

@ -204,6 +204,52 @@ class IsProjectMemberFilterBackend(FilterBackend):
return super().filter_queryset(request, queryset.distinct(), view) return super().filter_queryset(request, queryset.distinct(), view)
class BaseIsProjectAdminFilterBackend(object):
def get_project_ids(self, request, view):
project_id = None
if hasattr(view, "filter_fields") and "project" in view.filter_fields:
project_id = request.QUERY_PARAMS.get("project", None)
if request.user.is_authenticated() and request.user.is_superuser:
return None
if not request.user.is_authenticated():
return []
memberships_qs = Membership.objects.filter(user=request.user, is_owner=True)
if project_id:
memberships_qs = memberships_qs.filter(project_id=project_id)
projects_list = [membership.project_id for membership in memberships_qs]
return projects_list
class IsProjectAdminFilterBackend(FilterBackend, BaseIsProjectAdminFilterBackend):
def filter_queryset(self, request, queryset, view):
project_ids = self.get_project_ids(request, view)
if project_ids is None:
queryset = queryset
elif project_ids == []:
queryset = queryset.none()
else:
queryset = queryset.filter(project_id__in=project_ids)
return super().filter_queryset(request, queryset.distinct(), view)
class IsProjectAdminFromWebhookLogFilterBackend(FilterBackend, BaseIsProjectAdminFilterBackend):
def filter_queryset(self, request, queryset, view):
project_ids = self.get_project_ids(request, view)
if project_ids is None:
queryset = queryset
elif project_ids == []:
queryset = queryset.none()
else:
queryset = queryset.filter(webhook__project_id__in=project_ids)
return super().filter_queryset(request, queryset, view)
class TagsFilter(FilterBackend): class TagsFilter(FilterBackend):
def __init__(self, filter_name='tags'): def __init__(self, filter_name='tags'):

View File

@ -31,6 +31,13 @@ def get_typename_for_model_class(model:object, for_concrete_model=True) -> str:
return "{0}.{1}".format(model._meta.app_label, model._meta.model_name) return "{0}.{1}".format(model._meta.app_label, model._meta.model_name)
def get_typename_for_model_instance(model_instance):
"""
Get content type tuple from model instance.
"""
ct = ContentType.objects.get_for_model(model_instance)
return ".".join([ct.app_label, ct.model])
def reload_attribute(model_instance, attr_name): def reload_attribute(model_instance, attr_name):
"""Fetch the stored value of a model instance attribute. """Fetch the stored value of a model instance attribute.

View File

@ -18,6 +18,7 @@ import collections
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from taiga.base.utils import json from taiga.base.utils import json
from taiga.base.utils.db import get_typename_for_model_instance
from . import middleware as mw from . import middleware as mw
from . import backends from . import backends
@ -32,14 +33,6 @@ watched_types = set([
]) ])
def _get_type_for_model(model_instance):
"""
Get content type tuple from model instance.
"""
ct = ContentType.objects.get_for_model(model_instance)
return ".".join([ct.app_label, ct.model])
def emit_event(data:dict, routing_key:str, *, def emit_event(data:dict, routing_key:str, *,
sessionid:str=None, channel:str="events"): sessionid:str=None, channel:str="events"):
if not sessionid: if not sessionid:
@ -64,7 +57,7 @@ def emit_event_for_model(obj, *, type:str="change", channel:str="events",
assert hasattr(obj, "project_id") assert hasattr(obj, "project_id")
if not content_type: if not content_type:
content_type = _get_type_for_model(obj) content_type = get_typename_for_model_instance(obj)
projectid = getattr(obj, "project_id") projectid = getattr(obj, "project_id")
pk = getattr(obj, "pk", None) pk = getattr(obj, "pk", None)

View File

@ -17,13 +17,15 @@
from django.db.models import signals from django.db.models import signals
from django.dispatch import receiver from django.dispatch import receiver
from taiga.base.utils.db import get_typename_for_model_instance
from . import middleware as mw from . import middleware as mw
from . import events from . import events
def on_save_any_model(sender, instance, created, **kwargs): def on_save_any_model(sender, instance, created, **kwargs):
# Ignore any object that can not have project_id # Ignore any object that can not have project_id
content_type = events._get_type_for_model(instance) content_type = get_typename_for_model_instance(instance)
# Ignore any other events # Ignore any other events
if content_type not in events.watched_types: if content_type not in events.watched_types:
@ -39,7 +41,7 @@ def on_save_any_model(sender, instance, created, **kwargs):
def on_delete_any_model(sender, instance, **kwargs): def on_delete_any_model(sender, instance, **kwargs):
# Ignore any object that can not have project_id # Ignore any object that can not have project_id
content_type = events._get_type_for_model(instance) content_type = get_typename_for_model_instance(instance)
# Ignore any other changes # Ignore any other changes
if content_type not in events.watched_types: if content_type not in events.watched_types:

View File

@ -90,6 +90,10 @@ router.register(r"tasks/attachments", TaskAttachmentViewSet, base_name="task-att
router.register(r"issues/attachments", IssueAttachmentViewSet, base_name="issue-attachments") router.register(r"issues/attachments", IssueAttachmentViewSet, base_name="issue-attachments")
router.register(r"wiki/attachments", WikiAttachmentViewSet, base_name="wiki-attachments") router.register(r"wiki/attachments", WikiAttachmentViewSet, base_name="wiki-attachments")
# Webhooks
from taiga.webhooks.api import WebhookViewSet, WebhookLogViewSet
router.register(r"webhooks", WebhookViewSet, base_name="webhooks")
router.register(r"webhooklogs", WebhookLogViewSet, base_name="webhooklogs")
# History & Components # History & Components
from taiga.projects.history.api import UserStoryHistory from taiga.projects.history.api import UserStoryHistory

View File

@ -0,0 +1,17 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
default_app_config = "taiga.webhooks.apps.WebhooksAppConfig"

65
taiga/webhooks/api.py Normal file
View File

@ -0,0 +1,65 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import json
from django.shortcuts import get_object_or_404
from rest_framework.response import Response
from taiga.base import filters
from taiga.base.api import ModelCrudViewSet, ModelListViewSet
from taiga.base.decorators import detail_route
from . import models
from . import serializers
from . import permissions
from . import tasks
class WebhookViewSet(ModelCrudViewSet):
model = models.Webhook
serializer_class = serializers.WebhookSerializer
permission_classes = (permissions.WebhookPermission,)
filter_backends = (filters.IsProjectAdminFilterBackend,)
filter_fields = ("project",)
@detail_route(methods=["POST"])
def test(self, request, pk=None):
webhook = self.get_object()
self.check_permissions(request, 'test', webhook)
tasks.test_webhook(webhook.id, webhook.url, webhook.key)
return Response()
class WebhookLogViewSet(ModelListViewSet):
model = models.WebhookLog
serializer_class = serializers.WebhookLogSerializer
permission_classes = (permissions.WebhookLogPermission,)
filter_backends = (filters.IsProjectAdminFromWebhookLogFilterBackend,)
filter_fields = ("webhook",)
@detail_route(methods=["POST"])
def resend(self, request, pk=None):
webhooklog = self.get_object()
self.check_permissions(request, 'resend', webhooklog)
webhook = webhooklog.webhook
tasks.resend_webhook(webhook.id, webhook.url, webhook.key, webhooklog.request_data)
return Response()

37
taiga/webhooks/apps.py Normal file
View File

@ -0,0 +1,37 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.apps import AppConfig
from django.db.models import signals
from . import signal_handlers as handlers
from taiga.projects.history.models import HistoryEntry
def connect_webhooks_signals():
signals.post_save.connect(handlers.on_new_history_entry, sender=HistoryEntry, dispatch_uid="webhooks")
def disconnect_webhooks_signals():
signals.post_save.disconnect(dispatch_uid="webhooks")
class WebhooksAppConfig(AppConfig):
name = "taiga.webhooks"
verbose_name = "Webhooks App Config"
def ready(self):
connect_webhooks_signals()

View File

@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
import django_pgjson.fields
class Migration(migrations.Migration):
dependencies = [
('projects', '0015_auto_20141230_1212'),
]
operations = [
migrations.CreateModel(
name='Webhook',
fields=[
('id', models.AutoField(serialize=False, verbose_name='ID', auto_created=True, primary_key=True)),
('url', models.URLField(verbose_name='URL')),
('key', models.TextField(verbose_name='secret key')),
('project', models.ForeignKey(related_name='webhooks', to='projects.Project')),
],
options={
},
bases=(models.Model,),
),
migrations.CreateModel(
name='WebhookLog',
fields=[
('id', models.AutoField(serialize=False, verbose_name='ID', auto_created=True, primary_key=True)),
('url', models.URLField(verbose_name='URL')),
('status', models.IntegerField(verbose_name='Status code')),
('request_data', django_pgjson.fields.JsonField(verbose_name='Request data')),
('response_data', models.TextField(verbose_name='Response data')),
('webhook', models.ForeignKey(related_name='logs', to='webhooks.Webhook')),
],
options={
},
bases=(models.Model,),
),
]

View File

36
taiga/webhooks/models.py Normal file
View File

@ -0,0 +1,36 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.db import models
from django.utils.translation import ugettext_lazy as _
from django_pgjson.fields import JsonField
class Webhook(models.Model):
project = models.ForeignKey("projects.Project", null=False, blank=False,
related_name="webhooks")
url = models.URLField(null=False, blank=False, verbose_name=_("URL"))
key = models.TextField(null=False, blank=False, verbose_name=_("secret key"))
class WebhookLog(models.Model):
webhook = models.ForeignKey(Webhook, null=False, blank=False,
related_name="logs")
url = models.URLField(null=False, blank=False, verbose_name=_("URL"))
status = models.IntegerField(null=False, blank=False, verbose_name=_("Status code"))
request_data = JsonField(null=False, blank=False, verbose_name=_("Request data"))
response_data = models.TextField(null=False, blank=False, verbose_name=_("Response data"))

View File

@ -0,0 +1,40 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from taiga.base.api.permissions import (TaigaResourcePermission, IsProjectOwner,
AllowAny, PermissionComponent)
from taiga.permissions.service import is_project_owner
class IsWebhookProjectOwner(PermissionComponent):
def check_permissions(self, request, view, obj=None):
return is_project_owner(request.user, obj.webhook.project)
class WebhookPermission(TaigaResourcePermission):
retrieve_perms = IsProjectOwner()
create_perms = IsProjectOwner()
update_perms = IsProjectOwner()
destroy_perms = IsProjectOwner()
list_perms = AllowAny()
test_perms = IsProjectOwner()
class WebhookLogPermission(TaigaResourcePermission):
retrieve_perms = IsWebhookProjectOwner()
list_perms = AllowAny()
resend_perms = IsWebhookProjectOwner()

View File

@ -0,0 +1,131 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from rest_framework import serializers
from taiga.base.serializers import TagsField, PgArrayField, JsonField
from taiga.projects.userstories import models as us_models
from taiga.projects.tasks import models as task_models
from taiga.projects.issues import models as issue_models
from taiga.projects.milestones import models as milestone_models
from taiga.projects.history import models as history_models
from taiga.projects.wiki import models as wiki_models
from .models import Webhook, WebhookLog
class HistoryDiffField(serializers.Field):
def to_native(self, obj):
return {key: {"from": value[0], "to": value[1]} for key, value in obj.items()}
class WebhookSerializer(serializers.ModelSerializer):
class Meta:
model = Webhook
class WebhookLogSerializer(serializers.ModelSerializer):
request_data = JsonField()
class Meta:
model = WebhookLog
class UserSerializer(serializers.Serializer):
id = serializers.SerializerMethodField("get_pk")
name = serializers.SerializerMethodField("get_name")
def get_pk(self, obj):
return obj.pk
def get_name(self, obj):
return obj.full_name
class PointSerializer(serializers.Serializer):
id = serializers.SerializerMethodField("get_pk")
name = serializers.SerializerMethodField("get_name")
value = serializers.SerializerMethodField("get_value")
def get_pk(self, obj):
return obj.pk
def get_name(self, obj):
return obj.name
def get_value(self, obj):
return obj.value
class UserStorySerializer(serializers.ModelSerializer):
tags = TagsField(default=[], required=False)
external_reference = PgArrayField(required=False)
owner = UserSerializer()
assigned_to = UserSerializer()
watchers = UserSerializer(many=True)
points = PointSerializer(many=True)
class Meta:
model = us_models.UserStory
exclude = ("backlog_order", "sprint_order", "kanban_order", "version")
class TaskSerializer(serializers.ModelSerializer):
tags = TagsField(default=[], required=False)
owner = UserSerializer()
assigned_to = UserSerializer()
watchers = UserSerializer(many=True)
class Meta:
model = task_models.Task
class IssueSerializer(serializers.ModelSerializer):
tags = TagsField(default=[], required=False)
owner = UserSerializer()
assigned_to = UserSerializer()
watchers = UserSerializer(many=True)
class Meta:
model = issue_models.Issue
class WikiPageSerializer(serializers.ModelSerializer):
owner = UserSerializer()
last_modifier = UserSerializer()
watchers = UserSerializer(many=True)
class Meta:
model = wiki_models.WikiPage
exclude = ("watchers", "version")
class MilestoneSerializer(serializers.ModelSerializer):
owner = UserSerializer()
class Meta:
model = milestone_models.Milestone
exclude = ("order", "watchers")
class HistoryEntrySerializer(serializers.ModelSerializer):
diff = HistoryDiffField()
snapshot = JsonField()
values = JsonField()
user = JsonField()
delete_comment_user = JsonField()
class Meta:
model = history_models.HistoryEntry

View File

@ -0,0 +1,65 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django.conf import settings
from taiga.projects.history import services as history_service
from taiga.projects.history.choices import HistoryType
from . import tasks
def _get_project_webhooks(project):
webhooks = []
for webhook in project.webhooks.all():
webhooks.append({
"id": webhook.pk,
"url": webhook.url,
"key": webhook.key,
})
return webhooks
def on_new_history_entry(sender, instance, created, **kwargs):
if not settings.WEBHOOKS_ENABLED:
return None
if instance.is_hidden:
return None
model = history_service.get_model_from_key(instance.key)
pk = history_service.get_pk_from_key(instance.key)
obj = model.objects.get(pk=pk)
webhooks = _get_project_webhooks(obj.project)
if instance.type == HistoryType.create:
task = tasks.create_webhook
extra_args = []
elif instance.type == HistoryType.change:
task = tasks.change_webhook
extra_args = [instance]
elif instance.type == HistoryType.delete:
task = tasks.delete_webhook
extra_args = []
for webhook in webhooks:
args = [webhook["id"], webhook["url"], webhook["key"], obj] + extra_args
if settings.CELERY_ENABLED:
task.delay(*args)
else:
task(*args)

125
taiga/webhooks/tasks.py Normal file
View File

@ -0,0 +1,125 @@
# Copyright (C) 2013 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import hmac
import hashlib
import requests
from requests.exceptions import RequestException
from rest_framework.renderers import UnicodeJSONRenderer
from taiga.base.utils.db import get_typename_for_model_instance
from taiga.celery import app
from .serializers import (UserStorySerializer, IssueSerializer, TaskSerializer,
WikiPageSerializer, MilestoneSerializer,
HistoryEntrySerializer)
from .models import WebhookLog
def _serialize(obj):
content_type = get_typename_for_model_instance(obj)
if content_type == "userstories.userstory":
return UserStorySerializer(obj).data
elif content_type == "issues.issue":
return IssueSerializer(obj).data
elif content_type == "tasks.task":
return TaskSerializer(obj).data
elif content_type == "wiki.wikipage":
return WikiPageSerializer(obj).data
elif content_type == "milestones.milestone":
return MilestoneSerializer(obj).data
elif content_type == "history.historyentry":
return HistoryEntrySerializer(obj).data
def _get_type(obj):
content_type = get_typename_for_model_instance(obj)
return content_type.split(".")[1]
def _generate_signature(data, key):
mac = hmac.new(key.encode("utf-8"), msg=data, digestmod=hashlib.sha1)
return mac.hexdigest()
def _send_request(webhook_id, url, key, data):
serialized_data = UnicodeJSONRenderer().render(data)
signature = _generate_signature(serialized_data, key)
headers = {
"X-TAIGA-WEBHOOK-SIGNATURE": signature,
}
try:
response = requests.post(url, data=serialized_data, headers=headers)
WebhookLog.objects.create(webhook_id=webhook_id, url=url,
status=response.status_code,
request_data=data,
response_data=response.content)
except RequestException:
WebhookLog.objects.create(webhook_id=webhook_id, url=url, status=0,
request_data=data,
response_data="error-in-request")
ids = [webhook_log.id for webhook_log in WebhookLog.objects.filter(webhook_id=webhook_id).order_by("-id")[10:]]
WebhookLog.objects.filter(id__in=ids).delete()
@app.task
def change_webhook(webhook_id, url, key, obj, change):
data = {}
data['data'] = _serialize(obj)
data['action'] = "change"
data['type'] = _get_type(obj)
data['change'] = _serialize(change)
_send_request(webhook_id, url, key, data)
@app.task
def create_webhook(webhook_id, url, key, obj):
data = {}
data['data'] = _serialize(obj)
data['action'] = "create"
data['type'] = _get_type(obj)
_send_request(webhook_id, url, key, data)
@app.task
def delete_webhook(webhook_id, url, key, obj):
data = {}
data['data'] = _serialize(obj)
data['action'] = "delete"
data['type'] = _get_type(obj)
_send_request(webhook_id, url, key, data)
@app.task
def resend_webhook(webhook_id, url, key, data):
_send_request(webhook_id, url, key, data)
@app.task
def test_webhook(webhook_id, url, key):
data = {}
data['data'] = {"test": "test"}
data['action'] = "test"
data['type'] = "test"
_send_request(webhook_id, url, key, data)

View File

@ -197,6 +197,28 @@ class InvitationFactory(Factory):
email = factory.Sequence(lambda n: "user{}@email.com".format(n)) email = factory.Sequence(lambda n: "user{}@email.com".format(n))
class WebhookFactory(Factory):
class Meta:
model = "webhooks.Webhook"
strategy = factory.CREATE_STRATEGY
project = factory.SubFactory("tests.factories.ProjectFactory")
url = "http://localhost:8080/test"
key = "factory-key"
class WebhookLogFactory(Factory):
class Meta:
model = "webhooks.WebhookLog"
strategy = factory.CREATE_STRATEGY
webhook = factory.SubFactory("tests.factories.WebhookFactory")
url = "http://localhost:8080/test"
status = "200"
request_data = "test-request"
response_data = "test-response"
class StorageEntryFactory(Factory): class StorageEntryFactory(Factory):
class Meta: class Meta:
model = "userstorage.StorageEntry" model = "userstorage.StorageEntry"

View File

@ -0,0 +1,307 @@
from django.core.urlresolvers import reverse
from taiga.base.utils import json
from taiga.webhooks.serializers import WebhookSerializer
from taiga.webhooks.models import Webhook
from taiga.webhooks import tasks
from tests import factories as f
from tests.utils import helper_test_http_method, disconnect_signals, reconnect_signals
from unittest import mock
import pytest
pytestmark = pytest.mark.django_db
def setup_module(module):
disconnect_signals()
def teardown_module(module):
reconnect_signals()
@pytest.fixture
def data():
m = type("Models", (object,), {})
m.registered_user = f.UserFactory.create()
m.project_owner = f.UserFactory.create()
m.project1 = f.ProjectFactory(is_private=True,
anon_permissions=[],
public_permissions=[],
owner=m.project_owner)
m.project2 = f.ProjectFactory(is_private=True,
anon_permissions=[],
public_permissions=[],
owner=m.project_owner)
f.MembershipFactory(project=m.project1,
user=m.project_owner,
is_owner=True)
m.webhook1 = f.WebhookFactory(project=m.project1)
m.webhooklog1 = f.WebhookLogFactory(webhook=m.webhook1)
m.webhook2 = f.WebhookFactory(project=m.project2)
m.webhooklog2 = f.WebhookLogFactory(webhook=m.webhook2)
return m
def test_webhook_retrieve(client, data):
url1 = reverse('webhooks-detail', kwargs={"pk": data.webhook1.pk})
url2 = reverse('webhooks-detail', kwargs={"pk": data.webhook2.pk})
users = [
None,
data.registered_user,
data.project_owner
]
results = helper_test_http_method(client, 'get', url1, None, users)
assert results == [401, 403, 200]
results = helper_test_http_method(client, 'get', url2, None, users)
assert results == [401, 403, 403]
def test_webhook_update(client, data):
url1 = reverse('webhooks-detail', kwargs={"pk": data.webhook1.pk})
url2 = reverse('webhooks-detail', kwargs={"pk": data.webhook2.pk})
users = [
None,
data.registered_user,
data.project_owner
]
webhook_data = WebhookSerializer(data.webhook1).data
webhook_data["key"] = "test"
webhook_data = json.dumps(webhook_data)
results = helper_test_http_method(client, 'put', url1, webhook_data, users)
assert results == [401, 403, 200]
webhook_data = WebhookSerializer(data.webhook2).data
webhook_data["key"] = "test"
webhook_data = json.dumps(webhook_data)
results = helper_test_http_method(client, 'put', url2, webhook_data, users)
assert results == [401, 403, 403]
def test_webhook_delete(client, data):
url1 = reverse('webhooks-detail', kwargs={"pk": data.webhook1.pk})
url2 = reverse('webhooks-detail', kwargs={"pk": data.webhook2.pk})
users = [
None,
data.registered_user,
data.project_owner
]
results = helper_test_http_method(client, 'delete', url1, None, users)
assert results == [401, 403, 204]
results = helper_test_http_method(client, 'delete', url2, None, users)
assert results == [401, 403, 403]
def test_webhook_list(client, data):
url = reverse('webhooks-list')
response = client.get(url)
webhooks_data = json.loads(response.content.decode('utf-8'))
assert len(webhooks_data) == 0
assert response.status_code == 200
client.login(data.registered_user)
response = client.get(url)
webhooks_data = json.loads(response.content.decode('utf-8'))
assert len(webhooks_data) == 0
assert response.status_code == 200
client.login(data.project_owner)
response = client.get(url)
webhooks_data = json.loads(response.content.decode('utf-8'))
assert len(webhooks_data) == 1
assert response.status_code == 200
def test_webhook_create(client, data):
url = reverse('webhooks-list')
users = [
None,
data.registered_user,
data.project_owner
]
create_data = json.dumps({
"url": "http://test.com",
"key": "test",
"project": data.project1.pk,
})
results = helper_test_http_method(client, 'post', url, create_data, users, lambda: Webhook.objects.all().delete())
assert results == [401, 403, 201]
create_data = json.dumps({
"url": "http://test.com",
"key": "test",
"project": data.project2.pk,
})
results = helper_test_http_method(client, 'post', url, create_data, users, lambda: Webhook.objects.all().delete())
assert results == [401, 403, 403]
def test_webhook_patch(client, data):
url1 = reverse('webhooks-detail', kwargs={"pk": data.webhook1.pk})
url2 = reverse('webhooks-detail', kwargs={"pk": data.webhook2.pk})
users = [
None,
data.registered_user,
data.project_owner
]
patch_data = json.dumps({"key": "test"})
results = helper_test_http_method(client, 'patch', url1, patch_data, users)
assert results == [401, 403, 200]
patch_data = json.dumps({"key": "test"})
results = helper_test_http_method(client, 'patch', url2, patch_data, users)
assert results == [401, 403, 403]
def test_webhook_action_test(client, data):
url1 = reverse('webhooks-test', kwargs={"pk": data.webhook1.pk})
url2 = reverse('webhooks-test', kwargs={"pk": data.webhook2.pk})
users = [
None,
data.registered_user,
data.project_owner
]
with mock.patch('taiga.webhooks.tasks._send_request') as _send_request_mock:
results = helper_test_http_method(client, 'post', url1, None, users)
assert results == [404, 404, 200]
assert _send_request_mock.called == True
with mock.patch('taiga.webhooks.tasks._send_request') as _send_request_mock:
results = helper_test_http_method(client, 'post', url2, None, users)
assert results == [404, 404, 404]
assert _send_request_mock.called == False
def test_webhooklogs_list(client, data):
url = reverse('webhooklogs-list')
response = client.get(url)
webhooklogs_data = json.loads(response.content.decode('utf-8'))
assert len(webhooklogs_data) == 0
assert response.status_code == 200
client.login(data.registered_user)
response = client.get(url)
webhooklogs_data = json.loads(response.content.decode('utf-8'))
assert len(webhooklogs_data) == 0
assert response.status_code == 200
client.login(data.project_owner)
response = client.get(url)
webhooklogs_data = json.loads(response.content.decode('utf-8'))
assert len(webhooklogs_data) == 1
assert response.status_code == 200
def test_webhooklogs_retrieve(client, data):
url1 = reverse('webhooklogs-detail', kwargs={"pk": data.webhooklog1.pk})
url2 = reverse('webhooklogs-detail', kwargs={"pk": data.webhooklog2.pk})
users = [
None,
data.registered_user,
data.project_owner
]
results = helper_test_http_method(client, 'get', url1, None, users)
assert results == [401, 403, 200]
results = helper_test_http_method(client, 'get', url2, None, users)
assert results == [401, 403, 403]
def test_webhooklogs_create(client, data):
url1 = reverse('webhooklogs-list')
url2 = reverse('webhooklogs-list')
users = [
None,
data.registered_user,
data.project_owner
]
results = helper_test_http_method(client, 'post', url1, None, users)
assert results == [405, 405, 405]
results = helper_test_http_method(client, 'post', url2, None, users)
assert results == [405, 405, 405]
def test_webhooklogs_delete(client, data):
url1 = reverse('webhooklogs-detail', kwargs={"pk": data.webhooklog1.pk})
url2 = reverse('webhooklogs-detail', kwargs={"pk": data.webhooklog2.pk})
users = [
None,
data.registered_user,
data.project_owner
]
results = helper_test_http_method(client, 'delete', url1, None, users)
assert results == [405, 405, 405]
results = helper_test_http_method(client, 'delete', url2, None, users)
assert results == [405, 405, 405]
def test_webhooklogs_update(client, data):
url1 = reverse('webhooklogs-detail', kwargs={"pk": data.webhooklog1.pk})
url2 = reverse('webhooklogs-detail', kwargs={"pk": data.webhooklog2.pk})
users = [
None,
data.registered_user,
data.project_owner
]
results = helper_test_http_method(client, 'put', url1, None, users)
assert results == [405, 405, 405]
results = helper_test_http_method(client, 'put', url2, None, users)
assert results == [405, 405, 405]
results = helper_test_http_method(client, 'patch', url1, None, users)
assert results == [405, 405, 405]
results = helper_test_http_method(client, 'patch', url2, None, users)
assert results == [405, 405, 405]
def test_webhooklogs_action_resend(client, data):
url1 = reverse('webhooklogs-resend', kwargs={"pk": data.webhooklog1.pk})
url2 = reverse('webhooklogs-resend', kwargs={"pk": data.webhooklog2.pk})
users = [
None,
data.registered_user,
data.project_owner
]
results = helper_test_http_method(client, 'post', url1, None, users)
assert results == [404, 404, 200]
results = helper_test_http_method(client, 'post', url2, None, users)
assert results == [404, 404, 404]

View File

@ -0,0 +1,92 @@
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
# Copyright (C) 2014 Anler Hernández <hello@anler.me>
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import pytest
from unittest.mock import patch
from .. import factories as f
from taiga.projects.history import services
pytestmark = pytest.mark.django_db
def test_new_object_with_one_webhook(settings):
settings.WEBHOOKS_ENABLED = True
project = f.ProjectFactory()
f.WebhookFactory.create(project=project)
objects = [
f.IssueFactory.create(project=project),
f.TaskFactory.create(project=project),
f.UserStoryFactory.create(project=project),
f.WikiPageFactory.create(project=project)
]
for obj in objects:
with patch('taiga.webhooks.tasks.create_webhook') as create_webhook_mock:
services.take_snapshot(obj, user=obj.owner, comment="test")
assert create_webhook_mock.call_count == 1
for obj in objects:
with patch('taiga.webhooks.tasks.change_webhook') as change_webhook_mock:
services.take_snapshot(obj, user=obj.owner)
assert change_webhook_mock.call_count == 0
for obj in objects:
with patch('taiga.webhooks.tasks.change_webhook') as change_webhook_mock:
services.take_snapshot(obj, user=obj.owner, comment="test")
assert change_webhook_mock.call_count == 1
for obj in objects:
with patch('taiga.webhooks.tasks.delete_webhook') as delete_webhook_mock:
services.take_snapshot(obj, user=obj.owner, comment="test", delete=True)
assert delete_webhook_mock.call_count == 1
def test_new_object_with_two_webhook(settings):
settings.WEBHOOKS_ENABLED = True
project = f.ProjectFactory()
f.WebhookFactory.create(project=project)
f.WebhookFactory.create(project=project)
objects = [
f.IssueFactory.create(project=project),
f.TaskFactory.create(project=project),
f.UserStoryFactory.create(project=project),
f.WikiPageFactory.create(project=project)
]
for obj in objects:
with patch('taiga.webhooks.tasks.create_webhook') as create_webhook_mock:
services.take_snapshot(obj, user=obj.owner, comment="test")
assert create_webhook_mock.call_count == 2
for obj in objects:
with patch('taiga.webhooks.tasks.change_webhook') as change_webhook_mock:
services.take_snapshot(obj, user=obj.owner, comment="test")
assert change_webhook_mock.call_count == 2
for obj in objects:
with patch('taiga.webhooks.tasks.change_webhook') as change_webhook_mock:
services.take_snapshot(obj, user=obj.owner)
assert change_webhook_mock.call_count == 0
for obj in objects:
with patch('taiga.webhooks.tasks.delete_webhook') as delete_webhook_mock:
services.take_snapshot(obj, user=obj.owner, comment="test", delete=True)
assert delete_webhook_mock.call_count == 2