bkflow-django-webhook 1.2.0__py2.py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,138 @@
1
+ Metadata-Version: 2.4
2
+ Name: bkflow-django-webhook
3
+ Version: 1.2.0
4
+ Summary: A Django app to make it easy for integrating webhook into service.
5
+ Author-email: normal-wls <weishi.swee@qq.com>
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: Django >2, <4
9
+ Requires-Dist: pyyaml >5, <7
10
+ Requires-Dist: pydantic <3
11
+ Requires-Dist: pytest >=7.0.1,<8 ; extra == "test"
12
+ Requires-Dist: pytest-django>=4.5.2,<5.0 ; extra == "test"
13
+ Requires-Dist: djangorestframework >3, <4 ; extra == "test"
14
+ Project-URL: Home, https://github.com/TencentBlueKing/bkflow-django-webhook
15
+ Provides-Extra: test
16
+
17
+ # bkflow-django-webhook
18
+
19
+ ## 简介
20
+ bkflow-django-webhook 是一款支持系统快速集成 webhook 功能的 Django app。
21
+
22
+ webhook 是一种在特定事件发生时,通过 HTTP 请求将数据发送到指定的 URL 的实时通信的机制。
23
+
24
+ webhook 的使用场景非常广泛。例如,当用户在某个网站上进行了特定操作(如提交表单、创建账户或进行支付)时,网站可以使用 webhook 将相关数据发送给其他应用程序或服务。这样,接收方应用程序就能够实时获取到这些数据,并根据需要进行处理。
25
+
26
+ 集成 bkflow-django-webhook,可以让 Django 应用快速获得 webhook 所需要的能力,帮助应用实现基于事件的服务自动化调用。
27
+
28
+ ## 相关概念
29
+ ![bkflow_django_webhook_concepts](docs/pics/bkflow_django_webhook_concepts.png)
30
+
31
+ 上图描述了从事件触发到最终请求的过程,涉及以下几个概念:
32
+ 1. 请求配置 (Webhook) : 记录发送请求所需要的 endpoint、token 等信息,根据该配置发送 HTTP POST 请求完成对外部服务的调用。
33
+ 2. 事件 (Event) : 系统触发请求的类型,需要提前定义。
34
+ 3. 领域 (Scope) : `事件`的触发往往是因为资源的变更,而资源会有`领域`的划分,不同`领域`的资源往往需要发送不同的请求。(上图的 template 是资源,template 属于不同的业务,业务就是 Scope,不同业务下的资源事件可能会触发不同的 webhook 请求。比如 biz_1 业务下的 template 变更时,会通过 webhook 1 请求 service 1;而 biz_2 业务下的 template 变更时,会通过 webhook 2 请求 service 2。)
35
+ 4. 订阅 (Subscription) : 记录不同`事件`、`领域`和`请求配置`订阅关系的配置,用于在`事件`触发时筛选出对应的`请求配置`进行请求调用。
36
+
37
+ 在 bkflow-django-webhook 的设计中,资源 (图中 template) 的变更触发了事件 (Event),根据事件、资源所属领域 (Scope, 图中 biz) 和订阅 (Subscription) 的记录,筛选出命中的请求配置 (Webhook),最终对对应的服务 (service) 发送请求。
38
+
39
+
40
+ ## 快速上手
41
+
42
+ 下面以上图为例,说明如何快速上手。
43
+
44
+ #### 安装
45
+
46
+ ```shell
47
+ pip install bkflow-django-webhook
48
+ ```
49
+
50
+ #### 添加到 Django 项目中
51
+
52
+ 将 `webhook` 添加到 Django 项目中
53
+
54
+ ```python
55
+ INSTALLED_APPS = [
56
+ ...
57
+ 'webhook',
58
+ ]
59
+ ```
60
+
61
+ 建表
62
+
63
+ ```shell
64
+ python manage.py migrate webhook
65
+ ```
66
+
67
+ #### 事件定义
68
+
69
+ 创建定义资源文件,如 `webhook_events.yaml`
70
+
71
+ ```yaml
72
+ version: 1
73
+ events:
74
+ - code: template_update
75
+ name: 模板更新
76
+ - code: template_create
77
+ name: 模板创建
78
+ ```
79
+
80
+ 目前支持对多个事件进行定义,其中 `code` 定义事件唯一键,`name` 定义事件名称。
81
+
82
+ #### 事件同步
83
+
84
+ 通过 `sync_webhook_events` 命令进行事件同步(可以在每次应用启动时调用进行同步)
85
+
86
+ ```shell
87
+ python manage.py sync_webhook_events . webhook_events.yaml
88
+ ```
89
+
90
+ `sync_webhook_events` 命令包含两个位置参数:
91
+ - base_path: 资源文件所在的目录地址
92
+ - filename: 资源文件名
93
+
94
+ #### 资源注册
95
+
96
+ 通过 bkflow-django-webhook 的 api 注册 请求配置 和 订阅关系。
97
+
98
+ ```python
99
+ from webhook.api import apply_scope_subscriptions, apply_scope_webhooks
100
+
101
+ webhook_configs = [
102
+ {"code": "webhook1", "name": "webhook1", "endpoint": "https://xxx.com"}, # endpoint 是接收请求的服务地址
103
+ {"code": "webhook2", "name": "webhook2", "endpoint": "https://xxx.com"}
104
+ ]
105
+
106
+ subscription_configs = {"webhook1": ["*"], "webhook2": ["template_update"]} # "*" 表示订阅所有事件
107
+
108
+
109
+ apply_scope_webhooks(scope_type="biz", scope_code="biz1", webhooks=webhook_configs)
110
+ apply_scope_subscriptions(scope_type="biz", scope_code="biz1", subscription_configs=subscription_configs)
111
+
112
+ ```
113
+
114
+ #### 发起请求
115
+
116
+ ```python
117
+ from webhook.signals import event_broadcast_signal
118
+
119
+ # 在对应的业务逻辑中触发调用
120
+ event_broadcast_signal.send(
121
+ sender="template_update",
122
+ scopes=[("biz", "biz1")],
123
+ extra_info={"template_id": "template1"},
124
+ )
125
+ ```
126
+
127
+ 根据触发的事件(sender)、领域(scopes,可支持多个)过滤出对应的请求配置,并同步发送请求(extra_info将作为请求参数)。
128
+
129
+ 请求完成后,可在项目的 django admin 页面 Webhook/History 中查看请求历史。
130
+
131
+
132
+
133
+ ## 资料
134
+ - [Release](release.md)
135
+
136
+ ## 相关项目
137
+ - [bkflow-dmn](https://github.com/TencentBlueKing/bkflow-dmn)
138
+ - [bkflow-feel](https://github.com/TencentBlueKing/bkflow-feel)
@@ -0,0 +1,27 @@
1
+ webhook/__init__.py,sha256=iA7Cv8NwLSapU0y0lXf8poFhctgK9bTreCmYJRlBtDI,47
2
+ webhook/admin.py,sha256=Q_lfHqjZaT0dykteZowX8qlos1BkpmOyrxFFcv9LFz4,1180
3
+ webhook/api.py,sha256=Cjllel4CDAMknGQj7vOJ_xA6XIlyRXx8hhpsDFNqZek,3246
4
+ webhook/apps.py,sha256=OyZWMzSigoiWZmH2o6Z6PFc-0ojo6JRoIUxJ2XcvJO0,478
5
+ webhook/base_models.py,sha256=jaBJtW6FfaAtaghLgxz2eGEABgLyNNSzzb2Sch1mF6w,661
6
+ webhook/config.py,sha256=Qd4nQWo56jWnQM8HKxgQ0n19fAysjXiWPS4d-MvVQt8,1742
7
+ webhook/handlers.py,sha256=9kLKF1fEGjCfI924id0PE2ilVZSKFpGGbrtnvejUxIs,4791
8
+ webhook/models.py,sha256=f7tjVaBn-506yxxD52g501-RTzXkZwXTs9aPVJC2UAQ,5415
9
+ webhook/contrib/__init__.py,sha256=iwhKnzeBJLKxpRVjvzwiRE63_zNpIBfaKLITauVph-0,24
10
+ webhook/contrib/drf/__init__.py,sha256=iwhKnzeBJLKxpRVjvzwiRE63_zNpIBfaKLITauVph-0,24
11
+ webhook/contrib/drf/serializers.py,sha256=PQsul0ZubzvQSEQ5tJbKNpXs7jK7toLNwPTrGxPbxC8,1581
12
+ webhook/management/__init__.py,sha256=iwhKnzeBJLKxpRVjvzwiRE63_zNpIBfaKLITauVph-0,24
13
+ webhook/management/commands/__init__.py,sha256=iwhKnzeBJLKxpRVjvzwiRE63_zNpIBfaKLITauVph-0,24
14
+ webhook/management/commands/sync_webhook_events.py,sha256=AsYLCQ0ial0B7uq7t4r43fr1b5x1uKuJ4rpWbjYuDyg,1160
15
+ webhook/migrations/0001_initial.py,sha256=yg4EefKW73lyr33HcADAMbs4n19LepAFcBhhOOH_t9Y,3522
16
+ webhook/migrations/0002_auto_20240202_1148.py,sha256=wH0nVVWv2IJiqfztBpYCPMasyEHLuzXcG9JqMsWYNlQ,580
17
+ webhook/migrations/0003_auto_20250808_1826.py,sha256=rd7GGj1KYXnIwwiI91oWbWBvsEPR6q64ZMWr9Hv2PkY,950
18
+ webhook/migrations/0004_auto_20250814_1607.py,sha256=4c_Alio4Biwxlo3asaDEPURqo2O1duJHtPlKakyqvo4,441
19
+ webhook/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
+ webhook/requester/__init__.py,sha256=QnE5lIwX3LKO_lxHYVZoGb7XeQgOIZoChKl8FtwyATs,91
21
+ webhook/requester/base.py,sha256=nG6R-LMnxYA4SfpskEu98c9XzsgUPCwjDeBpY14jOzc,3971
22
+ webhook/signals/__init__.py,sha256=hevh_WJd8LjF3AGLKUQlq1-CJlwV7l5Kw6IGiQqQ-K0,419
23
+ webhook/signals/handlers.py,sha256=L5ny3mFJIlDG9U3LCUWg1bg4pFPABYrZRkq2eJhkc6A,486
24
+ bkflow_django_webhook-1.2.0.dist-info/licenses/LICENSE,sha256=efzBCdJWqcnPWzjy5lxFG5TJBRLwy3fnfNtHp0fuqJ8,1068
25
+ bkflow_django_webhook-1.2.0.dist-info/WHEEL,sha256=Dyt6SBfaasWElUrURkknVFAZDHSTwxg3PaTza7RSbkY,100
26
+ bkflow_django_webhook-1.2.0.dist-info/METADATA,sha256=hBSNq_OvWiSBOU2ADf0Hn8_-0lXYXi3v0WpYMd_xoBo,5083
27
+ bkflow_django_webhook-1.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: flit 3.12.0
3
+ Root-Is-Purelib: true
4
+ Tag: py2-none-any
5
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 腾讯蓝鲸
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
webhook/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ __version__ = "1.2.0"
webhook/admin.py ADDED
@@ -0,0 +1,37 @@
1
+ # -*- coding: utf-8 -*-
2
+ from django.contrib import admin
3
+
4
+ from webhook import models
5
+
6
+
7
+ @admin.register(models.Event)
8
+ class EventAdmin(admin.ModelAdmin):
9
+ list_display = [field.name for field in models.Event._meta.fields]
10
+ search_fields = ["code", "name"]
11
+
12
+
13
+ @admin.register(models.Webhook)
14
+ class WebhookAdmin(admin.ModelAdmin):
15
+ list_display = [field.name for field in models.Webhook._meta.fields]
16
+ search_fields = ["code", "name", "scope_code"]
17
+
18
+
19
+ @admin.register(models.History)
20
+ class HistoryAdmin(admin.ModelAdmin):
21
+ list_display = [field.name for field in models.History._meta.fields]
22
+ search_fields = ["delivery_id", "webhook_code", "event_code", "scope_code"]
23
+ list_filter = ["success", "status_code"]
24
+
25
+
26
+ @admin.register(models.Subscription)
27
+ class SubscriptionAdmin(admin.ModelAdmin):
28
+ list_display = [field.name for field in models.Subscription._meta.fields]
29
+ search_fields = ["webhook_code", "event_code", "scope_code"]
30
+ list_filter = ["event_code"]
31
+
32
+
33
+ @admin.register(models.Scope)
34
+ class ScopeAdmin(admin.ModelAdmin):
35
+ list_display = [field.name for field in models.Scope._meta.fields]
36
+ search_fields = ["code"]
37
+ list_filter = ["type"]
webhook/api.py ADDED
@@ -0,0 +1,90 @@
1
+ # -*- coding: utf-8 -*-
2
+ import logging
3
+ from typing import List, Tuple, Union, Dict
4
+
5
+ from webhook.config import webhook_settings
6
+
7
+ from webhook.base_models import Event, Scope, Webhook
8
+ from webhook.handlers import EventHandler
9
+ from webhook.models import Event as EventModel
10
+ from webhook.models import Subscription
11
+ from webhook.models import Webhook as WebhookModel
12
+ from webhook.requester import RequestConfig, Requester
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def get_scope_webhooks(scope_type: str, scope_code: str) -> List[Webhook]:
18
+ """
19
+ get webhooks of scope
20
+ """
21
+ scope = Scope(scope_type=scope_type, scope_code=scope_code)
22
+ return [
23
+ Webhook.from_orm(webhook)
24
+ for webhook in WebhookModel.objects.filter(scope_type=scope.type, scope_code=scope.code)
25
+ ]
26
+
27
+
28
+ def get_scope_subscribed_events(scope_type: str, scope_code: str, parse_all_event_key: bool = False, *args, **kwargs):
29
+ """
30
+ get subscriptions of scope
31
+ """
32
+ event_codes = Subscription.objects.filter(scope_type=scope_type, scope_code=scope_code).values_list(
33
+ "event_code", flat=True
34
+ )
35
+
36
+ if webhook_settings.ALL_EVENTS_KEY in event_codes:
37
+ return EventModel.objects.all().values_list("code", flat=True)
38
+
39
+ return event_codes
40
+
41
+
42
+ def apply_scope_webhooks(scope_type: str, scope_code: str, webhooks: List[Dict]) -> None:
43
+ """
44
+ update or create webhooks by given list and delete others
45
+ """
46
+ scope = Scope(type=scope_type, code=scope_code)
47
+ webhooks = [Webhook(**webhook, scope_type=scope_type, scope_code=scope_code) for webhook in webhooks]
48
+ WebhookModel.objects.apply_scope_webhooks(scope, webhooks)
49
+
50
+
51
+ def apply_scope_subscriptions(scope_type: str, scope_code: str, subscription_configs: Dict) -> None:
52
+ scope = Scope(type=scope_type, code=scope_code)
53
+ Subscription.objects.apply_scope_subscriptions(scope, subscription_configs)
54
+
55
+
56
+ def event_broadcast(event: Union[Event, str], scopes: List[Union[Scope, Tuple[str, str]]], *args, **kwargs):
57
+ """
58
+ broadcast event to make subscription webhooks send requests
59
+ """
60
+ logger.info(f"[event broadcasting...] event: {event}, scopes: {scopes}, args: {args}, kwargs: {kwargs}")
61
+ if isinstance(event, str):
62
+ event_instance = EventModel.objects.filter(code=event).first()
63
+ if not event_instance:
64
+ logger.error(f"event {event} not found")
65
+ return
66
+ event = Event.from_orm(event_instance)
67
+
68
+ extra_info = kwargs.get("extra_info", {})
69
+ if event.info:
70
+ event.info.update(extra_info)
71
+ else:
72
+ event.info = extra_info
73
+ if not isinstance(event, Event):
74
+ logger.error(f"event {event} is not a Event instance")
75
+ return
76
+
77
+ if not all([isinstance(scope, (Scope, tuple)) for scope in scopes]):
78
+ logger.error(f"scopes {scopes} is not a Scope or tuple instance")
79
+ return
80
+
81
+ scopes = [Scope(type=scope[0], code=scope[1]) if isinstance(scope, tuple) else scope for scope in scopes]
82
+
83
+ event_handler = EventHandler(event)
84
+ event_handler.handle(scopes=scopes)
85
+
86
+
87
+ def verify_webhook_endpoint(webhook_config):
88
+ request_config = RequestConfig(**webhook_config)
89
+ result = Requester(config=request_config.dict()).request()
90
+ return result
webhook/apps.py ADDED
@@ -0,0 +1,16 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class WebhookConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "webhook"
7
+
8
+ def ready(self):
9
+ from .signals import ( # noqa
10
+ event_broadcast_signal,
11
+ post_event_broadcast_signal,
12
+ post_send_request_signal,
13
+ pre_event_broadcast_signal,
14
+ pre_send_request_signal,
15
+ )
16
+ from .signals.handlers import handle_event_broadcast # noqa
webhook/base_models.py ADDED
@@ -0,0 +1,38 @@
1
+ # -*- coding: utf-8 -*-
2
+ from typing import Optional
3
+
4
+ from pydantic import BaseModel
5
+
6
+
7
+ class Event(BaseModel):
8
+ code: str
9
+ name: str
10
+ description: Optional[str] = None
11
+ info: Optional[dict] = None
12
+
13
+ class Config:
14
+ orm_mode = True
15
+ from_attributes = True
16
+
17
+
18
+ class Webhook(BaseModel):
19
+ code: str
20
+ name: str
21
+ method: str = "POST"
22
+ endpoint: str
23
+ scope_type: str
24
+ scope_code: str
25
+ extra_info: Optional[dict] = None
26
+
27
+ class Config:
28
+ orm_mode = True
29
+ from_attributes = True
30
+
31
+
32
+ class Scope(BaseModel):
33
+ type: str
34
+ code: str
35
+
36
+ class Config:
37
+ orm_mode = True
38
+ from_attributes = True
webhook/config.py ADDED
@@ -0,0 +1,52 @@
1
+ # -*- coding: utf-8 -*-
2
+ from django.conf import settings
3
+
4
+ DEFAULT_SETTINGS = {
5
+ "MODE": "SYNC", # SYNC or ASYNC
6
+ "ALL_EVENTS_KEY": "*",
7
+ "EVENT": {
8
+ "DATA_SOURCE": "DB", # DB or SETTINGS
9
+ },
10
+ "REQUEST": {
11
+ "TIMEOUT": 30,
12
+ },
13
+ }
14
+
15
+
16
+ class WebhookSettings:
17
+ SETTING_PREFIX = "WEBHOOK"
18
+ NESTING_SEPARATOR = "_"
19
+
20
+ def __init__(self, default_settings=None):
21
+ self.project_settings = self.get_flatten_settings(getattr(settings, self.SETTING_PREFIX, {}))
22
+ self.default_settings = self.get_flatten_settings(default_settings or DEFAULT_SETTINGS)
23
+
24
+ def __getattr__(self, key):
25
+ if key not in self.project_settings and key not in self.default_settings:
26
+ raise AttributeError
27
+
28
+ value = self.project_settings.get(key) or self.default_settings.get(key)
29
+ if value is not None:
30
+ setattr(self, key, value)
31
+ return value
32
+
33
+ def get_flatten_settings(self, inputted_settings: dict, cur_prefix: str = ""):
34
+ def get_cur_key(cur_key):
35
+ return f"{cur_prefix}{self.NESTING_SEPARATOR}{cur_key}" if cur_prefix else cur_key
36
+
37
+ flatten_settings = {}
38
+ for key, value in inputted_settings.items():
39
+ if isinstance(value, dict):
40
+ flatten_sub_settings = self.get_flatten_settings(value, key)
41
+ flatten_settings.update(
42
+ {
43
+ get_cur_key(flatten_key): flatten_value
44
+ for flatten_key, flatten_value in flatten_sub_settings.items()
45
+ }
46
+ )
47
+ else:
48
+ flatten_settings[get_cur_key(key)] = value
49
+ return flatten_settings
50
+
51
+
52
+ webhook_settings = WebhookSettings(DEFAULT_SETTINGS)
@@ -0,0 +1 @@
1
+ # -*- coding: utf-8 -*-
@@ -0,0 +1 @@
1
+ # -*- coding: utf-8 -*-
@@ -0,0 +1,32 @@
1
+ # -*- coding: utf-8 -*-
2
+ from django.utils.translation import ugettext_lazy as _
3
+ from rest_framework import serializers
4
+
5
+ from webhook.config import webhook_settings
6
+ from webhook.models import Event
7
+
8
+
9
+ class WebhookSerializer(serializers.Serializer):
10
+ code = serializers.CharField(help_text=_("webhook编码"), max_length=255, required=True)
11
+ name = serializers.CharField(help_text=_("webhook名称"), max_length=255, required=True)
12
+ endpoint = serializers.URLField(help_text=_("webhook endpoint"), max_length=255, required=True)
13
+ token = serializers.CharField(help_text=_("webhook token"), max_length=255, required=False)
14
+ extra_info = serializers.JSONField(help_text=_("额外扩展信息"), required=False)
15
+
16
+
17
+ class WebhookConfigsSerializer(serializers.Serializer):
18
+ webhooks = serializers.ListField(help_text=_("webhook列表"), child=WebhookSerializer(), required=True)
19
+
20
+
21
+ class WebhookWithEventsSerializer(WebhookSerializer):
22
+ events = serializers.ListField(help_text=_("webhook事件列表"), required=True)
23
+
24
+ def validate_events(self, events: list):
25
+ not_support_events = set(events) - set(Event.objects.all_events() + [webhook_settings.ALL_EVENTS_KEY])
26
+ if not_support_events:
27
+ raise serializers.ValidationError(_(f"校验失败,events中包含不支持的事件类型, 不支持事件类型: {not_support_events}"))
28
+ return events
29
+
30
+
31
+ class WebhookConfigsWithEventsSerializer(serializers.Serializer):
32
+ webhooks = serializers.ListField(help_text=_("webhook列表"), child=WebhookWithEventsSerializer(), required=True)
webhook/handlers.py ADDED
@@ -0,0 +1,129 @@
1
+ # -*- coding: utf-8 -*-
2
+ import abc
3
+ import uuid
4
+ from typing import Iterable, List
5
+
6
+ from django.db.models import Q
7
+ from celery import shared_task
8
+
9
+ from webhook.base_models import Event, Scope, Webhook
10
+ from webhook.config import webhook_settings
11
+ from webhook.models import History, Subscription
12
+ from webhook.models import Webhook as WebHookModel
13
+ from webhook.requester import RequestConfig, Requester, RequestResult
14
+ from webhook.signals import (
15
+ post_event_broadcast_signal,
16
+ post_send_request_signal,
17
+ pre_event_broadcast_signal,
18
+ pre_send_request_signal,
19
+ )
20
+
21
+
22
+ class HandlerInterface(abc.ABC):
23
+ @abc.abstractmethod
24
+ def handle(self, *args, **kwargs):
25
+ pass
26
+
27
+
28
+ class EventHandler(HandlerInterface):
29
+ def __init__(self, event: Event):
30
+ self.event = event
31
+
32
+ def handle(self, scopes: List[Scope], *args, **kwargs) -> None:
33
+ self._broadcast(scopes)
34
+
35
+ def _broadcast(self, scopes: List[Scope], *args, **kwargs):
36
+ pre_event_broadcast_signal.send(sender=self.__class__, event=self.event, scopes=scopes)
37
+
38
+ subscription_query = Q()
39
+ for scope in scopes:
40
+ subscription_query |= Q(
41
+ scope_type=scope.type,
42
+ scope_code=scope.code,
43
+ event_code__in=[self.event.code, webhook_settings.ALL_EVENTS_KEY],
44
+ )
45
+ subscriptions = (
46
+ list(
47
+ Subscription.objects.filter(subscription_query).values_list("webhook_code", "scope_type", "scope_code")
48
+ )
49
+ if scopes
50
+ else []
51
+ )
52
+ webhook_query = Q()
53
+ for webhook_code, scope_type, scope_code in subscriptions:
54
+ webhook_query |= Q(code=webhook_code, scope_type=scope_type, scope_code=scope_code)
55
+ webhooks = (
56
+ [Webhook.from_orm(webhook) for webhook in WebHookModel.objects.filter(webhook_query)]
57
+ if subscriptions
58
+ else []
59
+ )
60
+
61
+ # TODO: 优化支持异步实现
62
+ self._sync_webhooker_handle(webhooks=webhooks)
63
+
64
+ post_event_broadcast_signal.send(sender=self.__class__, event=self.event, scopes=scopes)
65
+
66
+ def _sync_webhooker_handle(self, webhooks: Iterable[Webhook], *args, **kwargs):
67
+ for webhook in webhooks:
68
+ webhooker = Webhooker(webhook)
69
+ webhooker.handle(event=self.event, *args, **kwargs)
70
+
71
+
72
+ class Webhooker(HandlerInterface):
73
+ def __init__(self, webhook: Webhook):
74
+ self.webhook = webhook
75
+
76
+ def handle(self, event: Event, *args, **kwargs):
77
+ self._request(event=event, *args, **kwargs)
78
+
79
+ def _request(self, event: Event, *args, **kwargs):
80
+ pre_send_request_signal.send(sender=self.__class__, webhook=self.webhook, event=event)
81
+ delivery_id = kwargs.pop("delivery_id", uuid.uuid4().hex)
82
+ request_config = RequestConfig(url=self.webhook.endpoint, method=self.webhook.method, **self.webhook.extra_info)
83
+ request_config.data.update({"event": event.dict(), "delivery_id": delivery_id})
84
+
85
+ self.send_webhook_task.apply_async(
86
+ kwargs={
87
+ "request_config": request_config.dict(),
88
+ "event_code": event.code,
89
+ "delivery_id": delivery_id,
90
+ "webhook_data": {
91
+ 'code': self.webhook.code,
92
+ 'scope_type': self.webhook.scope_type,
93
+ 'scope_code': self.webhook.scope_code,
94
+ "retry_times": self.webhook.extra_info.get("retry_times", 2),
95
+ "interval": self.webhook.extra_info.get("interval", 2),
96
+ },
97
+ **kwargs
98
+ }
99
+ )
100
+ post_send_request_signal.send(sender=self.__class__, webhook=self.webhook, event=event)
101
+
102
+ @staticmethod
103
+ @shared_task(bind=True)
104
+ def send_webhook_task(self, request_config, event_code, delivery_id, webhook_data, **kwargs):
105
+ request_result = Requester(config=request_config).request()
106
+
107
+ history_extra_info = {
108
+ "request": request_config,
109
+ "response": request_result.json_response(),
110
+ **kwargs.get("history_extra_info", {}),
111
+ }
112
+ # TODO: 可配置
113
+ History.objects.create(
114
+ webhook_code=webhook_data['code'],
115
+ event_code=event_code,
116
+ success=request_result.ok,
117
+ status_code=request_result.response_status_code,
118
+ delivery_id=delivery_id,
119
+ scope_type=webhook_data['scope_type'],
120
+ scope_code=webhook_data['scope_code'],
121
+ extra_info=history_extra_info,
122
+ )
123
+
124
+ if not request_result.ok:
125
+ raise self.retry(
126
+ exc=Exception("Webhook request failed"),
127
+ countdown=webhook_data["retry_times"],
128
+ max_retries=webhook_data["interval"]
129
+ )
@@ -0,0 +1 @@
1
+ # -*- coding: utf-8 -*-
@@ -0,0 +1 @@
1
+ # -*- coding: utf-8 -*-
@@ -0,0 +1,35 @@
1
+ # -*- coding: utf-8 -*-
2
+ from os import path
3
+
4
+ import yaml
5
+ from django.core.management.base import BaseCommand
6
+
7
+ from webhook.models import Event
8
+
9
+
10
+ class Command(BaseCommand):
11
+ """
12
+ python manage.py sync_webhook_events base_path filename
13
+ """
14
+
15
+ def add_arguments(self, parser):
16
+ parser.add_argument("base_path", type=str, help="base_path")
17
+ parser.add_argument("filename", type=str, help="filename")
18
+
19
+ def handle(self, *args, **kwargs):
20
+ base_path = kwargs["base_path"]
21
+ filename = kwargs["filename"]
22
+ self.stdout.write(f"sync events and scopes with filename: {filename}")
23
+
24
+ with open(path.join(base_path, filename), "r", encoding="utf-8") as f:
25
+ webhook_config = yaml.load(f, Loader=yaml.FullLoader)
26
+
27
+ # sync events
28
+ events = webhook_config.get("events", [])
29
+ for event in events:
30
+ if not event.get("code"):
31
+ self.stdout.error(f"event code is required, event: {event}")
32
+ continue
33
+ Event.objects.get_or_create(
34
+ code=event["code"], name=event["name"], description=event.get("description", "")
35
+ )
@@ -0,0 +1,80 @@
1
+ # Generated by Django 3.2.15 on 2023-07-15 17:58
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ initial = True
9
+
10
+ dependencies = []
11
+
12
+ operations = [
13
+ migrations.CreateModel(
14
+ name="Event",
15
+ fields=[
16
+ ("code", models.CharField(max_length=255, primary_key=True, serialize=False)),
17
+ ("name", models.CharField(max_length=255)),
18
+ ("description", models.TextField(blank=True, null=True)),
19
+ ("info", models.JSONField(blank=True, null=True)),
20
+ ],
21
+ ),
22
+ migrations.CreateModel(
23
+ name="Webhook",
24
+ fields=[
25
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
26
+ ("code", models.CharField(max_length=255)),
27
+ ("name", models.CharField(max_length=255)),
28
+ ("endpoint", models.URLField()),
29
+ ("scope_type", models.CharField(max_length=64)),
30
+ ("scope_code", models.CharField(max_length=64)),
31
+ ("token", models.CharField(blank=True, max_length=255, null=True)),
32
+ ("extra_info", models.JSONField(blank=True, null=True)),
33
+ ],
34
+ options={
35
+ "unique_together": {("scope_type", "scope_code", "code")},
36
+ },
37
+ ),
38
+ migrations.CreateModel(
39
+ name="Subscription",
40
+ fields=[
41
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
42
+ ("webhook_code", models.CharField(db_index=True, max_length=255)),
43
+ ("event_code", models.CharField(db_index=True, max_length=255)),
44
+ ("scope_type", models.CharField(max_length=64)),
45
+ ("scope_code", models.CharField(max_length=64)),
46
+ ],
47
+ options={
48
+ "index_together": {("scope_type", "scope_code", "event_code", "webhook_code")},
49
+ },
50
+ ),
51
+ migrations.CreateModel(
52
+ name="Scope",
53
+ fields=[
54
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
55
+ ("type", models.CharField(max_length=64)),
56
+ ("code", models.CharField(max_length=64)),
57
+ ],
58
+ options={
59
+ "unique_together": {("type", "code")},
60
+ },
61
+ ),
62
+ migrations.CreateModel(
63
+ name="History",
64
+ fields=[
65
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
66
+ ("webhook_code", models.CharField(db_index=True, max_length=255)),
67
+ ("event_code", models.CharField(db_index=True, max_length=255)),
68
+ ("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
69
+ ("success", models.BooleanField()),
70
+ ("status_code", models.IntegerField(blank=True, default=None, null=True)),
71
+ ("delivery_id", models.CharField(db_index=True, max_length=64)),
72
+ ("scope_type", models.CharField(max_length=64)),
73
+ ("scope_code", models.CharField(max_length=64)),
74
+ ("extra_info", models.JSONField(blank=True, null=True)),
75
+ ],
76
+ options={
77
+ "index_together": {("scope_type", "scope_code", "webhook_code", "event_code")},
78
+ },
79
+ ),
80
+ ]
@@ -0,0 +1,25 @@
1
+ # Generated by Django 3.2.15 on 2024-02-02 03:48
2
+
3
+ from django.db import migrations
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('webhook', '0001_initial'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AlterModelOptions(
14
+ name='history',
15
+ options={'ordering': ['-id']},
16
+ ),
17
+ migrations.AlterModelOptions(
18
+ name='subscription',
19
+ options={'ordering': ['-id']},
20
+ ),
21
+ migrations.AlterModelOptions(
22
+ name='webhook',
23
+ options={'ordering': ['-id']},
24
+ ),
25
+ ]
@@ -0,0 +1,32 @@
1
+ # Generated by Django 3.2.25 on 2025-08-08 10:26
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('webhook', '0002_auto_20240202_1148'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.RemoveField(
14
+ model_name='webhook',
15
+ name='token',
16
+ ),
17
+ migrations.AddField(
18
+ model_name='webhook',
19
+ name='interval',
20
+ field=models.IntegerField(default=2, verbose_name='重试间隔(秒)'),
21
+ ),
22
+ migrations.AddField(
23
+ model_name='webhook',
24
+ name='method',
25
+ field=models.CharField(choices=[('GET', 'get'), ('POST', 'post')], default='POST', max_length=10, verbose_name='请求方法'),
26
+ ),
27
+ migrations.AddField(
28
+ model_name='webhook',
29
+ name='retry_times',
30
+ field=models.IntegerField(default=2, verbose_name='重试次数'),
31
+ ),
32
+ ]
@@ -0,0 +1,21 @@
1
+ # Generated by Django 3.2.25 on 2025-08-14 08:07
2
+
3
+ from django.db import migrations
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('webhook', '0003_auto_20250808_1826'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.RemoveField(
14
+ model_name='webhook',
15
+ name='interval',
16
+ ),
17
+ migrations.RemoveField(
18
+ model_name='webhook',
19
+ name='retry_times',
20
+ ),
21
+ ]
File without changes
webhook/models.py ADDED
@@ -0,0 +1,142 @@
1
+ # -*- coding: utf-8 -*-
2
+ from typing import Dict, List
3
+
4
+ from django.db import models, transaction
5
+ from django.db.models import Q
6
+
7
+ from webhook.base_models import Scope as ScopeBaseModel
8
+ from webhook.base_models import Webhook as WebhookBaseModel
9
+
10
+
11
+ class WebhookManager(models.Manager):
12
+ def apply_scope_webhooks(self, scope: ScopeBaseModel, webhooks: List[WebhookBaseModel]):
13
+ """
14
+ apply the given webhooks to the specified scope
15
+ need to guarantee the scope in webhooks is the same as the scope parameter
16
+ """
17
+ codes = set([webhook.code for webhook in webhooks])
18
+ with transaction.atomic():
19
+ Scope.objects.get_or_create(type=scope.type, code=scope.code)
20
+ # delete ones
21
+ self.filter(scope_type=scope.type, scope_code=scope.code).exclude(code__in=codes).delete()
22
+
23
+ # create or update ones
24
+ existing_webhook_info = self.filter(scope_type=scope.type, scope_code=scope.code).values("code", "id")
25
+ existing_webhook_code2id = {info["code"]: info["id"] for info in existing_webhook_info}
26
+ create_ones = [
27
+ Webhook(**webhook.dict()) for webhook in webhooks if webhook.code not in existing_webhook_code2id
28
+ ]
29
+ self.bulk_create(create_ones)
30
+ # update ones
31
+ update_ones = [
32
+ Webhook(id=existing_webhook_code2id[webhook.code], **webhook.dict())
33
+ for webhook in webhooks
34
+ if webhook.code in existing_webhook_code2id
35
+ ]
36
+ self.bulk_update(update_ones, fields=["name", "method", "endpoint", "extra_info"])
37
+
38
+
39
+ class Webhook(models.Model):
40
+ # HTTP 请求方法选项
41
+ METHOD_CHOICES = [
42
+ ('GET', 'get'),
43
+ ('POST', 'post')
44
+ ]
45
+
46
+ code = models.CharField(max_length=255)
47
+ name = models.CharField(max_length=255)
48
+ method = models.CharField("请求方法", max_length=10, choices=METHOD_CHOICES, default="POST")
49
+ endpoint = models.URLField()
50
+ scope_type = models.CharField(max_length=64)
51
+ scope_code = models.CharField(max_length=64)
52
+ extra_info = models.JSONField(null=True, blank=True)
53
+
54
+ objects = WebhookManager()
55
+
56
+ class Meta:
57
+ unique_together = ("scope_type", "scope_code", "code")
58
+ ordering = ["-id"]
59
+
60
+
61
+
62
+ class History(models.Model):
63
+ webhook_code = models.CharField(max_length=255, db_index=True)
64
+ event_code = models.CharField(max_length=255, db_index=True)
65
+ created_at = models.DateTimeField(auto_now_add=True, db_index=True)
66
+ success = models.BooleanField()
67
+ status_code = models.IntegerField(null=True, blank=True, default=None)
68
+ delivery_id = models.CharField(max_length=64, db_index=True)
69
+ scope_type = models.CharField(max_length=64)
70
+ scope_code = models.CharField(max_length=64)
71
+ extra_info = models.JSONField(null=True, blank=True) # request、response、time
72
+
73
+ class Meta:
74
+ index_together = ("scope_type", "scope_code", "webhook_code", "event_code")
75
+ ordering = ["-id"]
76
+
77
+
78
+ class SubscriptionManager(models.Manager):
79
+ def apply_scope_subscriptions(self, scope: ScopeBaseModel, subscription_configs: Dict[str, List[str]]):
80
+ """
81
+ subscription_configs: {webhook_code: [event_code]}
82
+ """
83
+ with transaction.atomic():
84
+ flatten_subscription_configs = [
85
+ (webhook_code, event_code)
86
+ for webhook_code, event_codes in subscription_configs.items()
87
+ for event_code in event_codes
88
+ ]
89
+ query_filter = Q()
90
+ for webhook_code, event_code in flatten_subscription_configs:
91
+ query_filter |= Q(webhook_code=webhook_code, event_code=event_code)
92
+
93
+ # delete
94
+ self.filter(scope_type=scope.type, scope_code=scope.code).exclude(query_filter).delete()
95
+
96
+ # create
97
+ existing_ones = self.filter(scope_type=scope.type, scope_code=scope.code).values_list(
98
+ "webhook_code", "event_code"
99
+ )
100
+ create_configs = set(flatten_subscription_configs) - set(existing_ones)
101
+ create_ones = [
102
+ Subscription(
103
+ scope_type=scope.type, scope_code=scope.code, webhook_code=webhook_code, event_code=event_code
104
+ )
105
+ for webhook_code, event_code in create_configs
106
+ ]
107
+ self.bulk_create(create_ones)
108
+
109
+
110
+ class Subscription(models.Model):
111
+ webhook_code = models.CharField(max_length=255, db_index=True)
112
+ event_code = models.CharField(max_length=255, db_index=True)
113
+ scope_type = models.CharField(max_length=64)
114
+ scope_code = models.CharField(max_length=64)
115
+
116
+ objects = SubscriptionManager()
117
+
118
+ class Meta:
119
+ index_together = ("scope_type", "scope_code", "event_code", "webhook_code")
120
+ ordering = ["-id"]
121
+
122
+
123
+ class EventManager(models.Manager):
124
+ def all_events(self) -> list:
125
+ return list(self.all().values_list("code", flat=True))
126
+
127
+
128
+ class Event(models.Model):
129
+ code = models.CharField(max_length=255, primary_key=True)
130
+ name = models.CharField(max_length=255)
131
+ description = models.TextField(null=True, blank=True)
132
+ info = models.JSONField(null=True, blank=True)
133
+
134
+ objects = EventManager()
135
+
136
+
137
+ class Scope(models.Model):
138
+ type = models.CharField(max_length=64)
139
+ code = models.CharField(max_length=64)
140
+
141
+ class Meta:
142
+ unique_together = ("type", "code")
@@ -0,0 +1,3 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ from .base import RequestConfig, Requester, RequestResult # noqa
@@ -0,0 +1,126 @@
1
+ # -*- coding: utf-8 -*-
2
+ import base64
3
+ import json
4
+ import logging
5
+
6
+ import requests
7
+ from pydantic import BaseModel
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ JSON_CONTENT_TYPE = "application/json"
12
+ FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"
13
+
14
+
15
+ class RequestConfig(BaseModel):
16
+ url: str
17
+ method: str = "post"
18
+ content_type: str = JSON_CONTENT_TYPE
19
+ params: dict = {}
20
+ headers: dict = None
21
+ data: dict = {} # convert to json when content_type is JSON_CONTENT_TYPE
22
+ verify: bool = False
23
+ timeout: int = None
24
+ authorization: dict = None
25
+
26
+ @staticmethod
27
+ def _gen_default_headers(content_type, authorization=None):
28
+ headers = {"Content-Type": content_type}
29
+ if not authorization:
30
+ return headers
31
+ auth_type = authorization.get("type", "").lower()
32
+
33
+ if auth_type == "bearer":
34
+ token = authorization.get("token", "")
35
+ headers["Authorization"] = f"Bearer {token}"
36
+ elif auth_type == "basic":
37
+ username = authorization.get("username", "")
38
+ password = authorization.get("password", "")
39
+ if username and password:
40
+ auth_str = f"{username}:{password}".encode("utf-8")
41
+ headers["Authorization"] = "Basic " + base64.b64encode(auth_str).decode("ascii")
42
+
43
+ return headers
44
+
45
+ def __init__(self, **kwargs):
46
+ kwargs["headers"] = {
47
+ **self._gen_default_headers(
48
+ content_type=kwargs.get("content_type", JSON_CONTENT_TYPE),
49
+ authorization=kwargs.get("authorization")
50
+ ),
51
+ **self._normalize_headers(kwargs.get("headers"))
52
+ }
53
+ super().__init__(**kwargs)
54
+
55
+ @staticmethod
56
+ def _normalize_headers(headers):
57
+ """将headers统一转换为标准字典格式"""
58
+ if headers is None:
59
+ return {}
60
+ if isinstance(headers, dict):
61
+ return headers.copy()
62
+ if isinstance(headers, list):
63
+ normalized = {}
64
+ for item in headers:
65
+ if not isinstance(item, dict) or 'key' not in item:
66
+ continue
67
+ key = item['key']
68
+ value = item.get('value')
69
+ if isinstance(value, (dict, list)):
70
+ value = json.dumps(value) if value else ''
71
+ elif not isinstance(value, str):
72
+ value = str(value)
73
+ normalized[key] = value
74
+ return normalized
75
+
76
+ raise ValueError(f"Unsupported headers type: {type(headers)}")
77
+
78
+ def dict(self, **kwargs):
79
+ result = super().dict(**kwargs)
80
+ content_type = result.pop("content_type")
81
+ result.pop("authorization")
82
+ if content_type == JSON_CONTENT_TYPE:
83
+ result["json"] = result.pop("data")
84
+ return result
85
+
86
+
87
+ class RequestResult(BaseModel):
88
+ result: bool
89
+ response: requests.Response = None
90
+ exe_data: str = None
91
+
92
+ class Config:
93
+ arbitrary_types_allowed = True
94
+
95
+ @property
96
+ def ok(self):
97
+ return self.result and self.response.ok
98
+
99
+ @property
100
+ def response_status_code(self):
101
+ if self.response is None:
102
+ return None
103
+ return self.response.status_code
104
+
105
+ def json_response(self):
106
+ if self.response is None:
107
+ return None
108
+ try:
109
+ return self.response.json()
110
+ except Exception as e:
111
+ logger.exception(f"[RequestResult.json_response error] {e}")
112
+ return self.response.text
113
+
114
+
115
+ class Requester:
116
+ def __init__(self, config: dict):
117
+ self.request_config = config
118
+
119
+ def request(self, *args, **kwargs) -> RequestResult:
120
+ try:
121
+ response = requests.request(**self.request_config)
122
+ except Exception as e:
123
+ logger.exception(f"[Requester.request error] {e}")
124
+ return RequestResult(result=False, exe_data=str(e))
125
+ else:
126
+ return RequestResult(result=True, response=response)
@@ -0,0 +1,10 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ from django.dispatch import Signal
4
+
5
+ event_broadcast_signal = Signal(providing_args=["scopes", "extra_info"])
6
+
7
+ pre_event_broadcast_signal = Signal(providing_args=["event", "scope"])
8
+ post_event_broadcast_signal = Signal(providing_args=["event", "scope"])
9
+ pre_send_request_signal = Signal(providing_args=["webhook", "event"])
10
+ post_send_request_signal = Signal(providing_args=["webhook", "event"])
@@ -0,0 +1,15 @@
1
+ # -*- coding: utf-8 -*-
2
+ from typing import List, Tuple, Union
3
+
4
+ from django.dispatch import receiver
5
+
6
+ from webhook.api import event_broadcast
7
+ from webhook.base_models import Event, Scope
8
+ from webhook.signals import event_broadcast_signal
9
+
10
+
11
+ @receiver(event_broadcast_signal)
12
+ def handle_event_broadcast(
13
+ sender: Union[Event, str], scopes: List[Union[Scope, Tuple[str, str]]], extra_info: dict = None, **kwargs
14
+ ):
15
+ event_broadcast(event=sender, scopes=scopes, extra_info=extra_info)