Merge pull request #209 from taigaio/us/1678/webhooks
US #1678: Add webhooks to the backendremotes/origin/enhancement/email-actions
commit
2fba748487
|
@ -197,6 +197,7 @@ INSTALLED_APPS = [
|
|||
"taiga.hooks.github",
|
||||
"taiga.hooks.gitlab",
|
||||
"taiga.hooks.bitbucket",
|
||||
"taiga.webhooks",
|
||||
|
||||
"rest_framework",
|
||||
"djmail",
|
||||
|
@ -367,6 +368,7 @@ GITLAB_VALID_ORIGIN_IPS = []
|
|||
|
||||
EXPORTS_TTL = 60 * 60 * 24 # 24 hours
|
||||
CELERY_ENABLED = False
|
||||
WEBHOOKS_ENABLED = False
|
||||
|
||||
# NOTE: DON'T INSERT MORE SETTINGS AFTER THIS LINE
|
||||
TEST_RUNNER="django.test.runner.DiscoverRunner"
|
||||
|
|
|
@ -19,6 +19,7 @@ from .development import *
|
|||
SKIP_SOUTH_TESTS = True
|
||||
SOUTH_TESTS_MIGRATE = False
|
||||
CELERY_ALWAYS_EAGER = True
|
||||
CELERY_ENABLED = False
|
||||
|
||||
MEDIA_ROOT = "/tmp"
|
||||
|
||||
|
|
|
@ -204,6 +204,52 @@ class IsProjectMemberFilterBackend(FilterBackend):
|
|||
|
||||
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):
|
||||
def __init__(self, filter_name='tags'):
|
||||
|
|
|
@ -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)
|
||||
|
||||
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):
|
||||
"""Fetch the stored value of a model instance attribute.
|
||||
|
|
|
@ -18,6 +18,7 @@ import collections
|
|||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from taiga.base.utils import json
|
||||
from taiga.base.utils.db import get_typename_for_model_instance
|
||||
from . import middleware as mw
|
||||
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, *,
|
||||
sessionid:str=None, channel:str="events"):
|
||||
if not sessionid:
|
||||
|
@ -64,7 +57,7 @@ def emit_event_for_model(obj, *, type:str="change", channel:str="events",
|
|||
assert hasattr(obj, "project_id")
|
||||
|
||||
if not content_type:
|
||||
content_type = _get_type_for_model(obj)
|
||||
content_type = get_typename_for_model_instance(obj)
|
||||
|
||||
projectid = getattr(obj, "project_id")
|
||||
pk = getattr(obj, "pk", None)
|
||||
|
|
|
@ -17,13 +17,15 @@
|
|||
from django.db.models import signals
|
||||
from django.dispatch import receiver
|
||||
|
||||
from taiga.base.utils.db import get_typename_for_model_instance
|
||||
|
||||
from . import middleware as mw
|
||||
from . import events
|
||||
|
||||
|
||||
def on_save_any_model(sender, instance, created, **kwargs):
|
||||
# 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
|
||||
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):
|
||||
# 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
|
||||
if content_type not in events.watched_types:
|
||||
|
|
|
@ -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"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
|
||||
from taiga.projects.history.api import UserStoryHistory
|
||||
|
|
|
@ -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"
|
|
@ -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()
|
|
@ -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()
|
|
@ -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,),
|
||||
),
|
||||
]
|
|
@ -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"))
|
|
@ -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()
|
|
@ -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
|
|
@ -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)
|
|
@ -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)
|
||||
|
|
@ -197,6 +197,28 @@ class InvitationFactory(Factory):
|
|||
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 Meta:
|
||||
model = "userstorage.StorageEntry"
|
||||
|
|
|
@ -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]
|
|
@ -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
|
Loading…
Reference in New Issue