forked from erp-dev/erp
feat: mission category
This commit is contained in:
@@ -1,20 +1,52 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from notifier.models import Notifier
|
||||
from notifier.models import Notifier, NotifierRoute
|
||||
|
||||
|
||||
class NotifierRouteInline(admin.TabularInline):
|
||||
model = NotifierRoute
|
||||
extra = 0
|
||||
fields = ["event_key", "mission_category", "is_enabled", "description"]
|
||||
|
||||
|
||||
@admin.register(Notifier)
|
||||
class NotifierAdmin(admin.ModelAdmin):
|
||||
inlines = [NotifierRouteInline]
|
||||
list_display = [
|
||||
"id",
|
||||
"merchant",
|
||||
"name",
|
||||
"event_key",
|
||||
"channel",
|
||||
"template_key",
|
||||
"is_enabled",
|
||||
"created_at",
|
||||
]
|
||||
list_filter = ["merchant", "event_key", "channel", "is_enabled", "created_at"]
|
||||
list_filter = ["merchant", "channel", "is_enabled", "created_at"]
|
||||
search_fields = ["name", "template_key", "description"]
|
||||
readonly_fields = ["created_at", "updated_at"]
|
||||
|
||||
def save_formset(self, request, form, formset, change):
|
||||
instances = formset.save(commit=False)
|
||||
for obj in formset.deleted_objects:
|
||||
obj.delete()
|
||||
for instance in instances:
|
||||
if isinstance(instance, NotifierRoute):
|
||||
instance.merchant = form.instance.merchant
|
||||
instance.save()
|
||||
formset.save_m2m()
|
||||
|
||||
|
||||
@admin.register(NotifierRoute)
|
||||
class NotifierRouteAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"id",
|
||||
"merchant",
|
||||
"notifier",
|
||||
"event_key",
|
||||
"mission_category",
|
||||
"is_enabled",
|
||||
"created_at",
|
||||
]
|
||||
list_filter = ["merchant", "event_key", "mission_category", "is_enabled", "created_at"]
|
||||
search_fields = ["notifier__name", "description"]
|
||||
readonly_fields = ["created_at", "updated_at"]
|
||||
|
||||
65
notifier/migrations/0002_notifierroute_refactor.py
Normal file
65
notifier/migrations/0002_notifierroute_refactor.py
Normal file
@@ -0,0 +1,65 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
from django.db.models import Q
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
Notifier = apps.get_model("notifier", "Notifier")
|
||||
NotifierRoute = apps.get_model("notifier", "NotifierRoute")
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
for notifier in Notifier.objects.using(db_alias).all().only("id", "merchant_id", "event_key").iterator():
|
||||
NotifierRoute.objects.using(db_alias).get_or_create(
|
||||
notifier_id=notifier.id,
|
||||
event_key=notifier.event_key,
|
||||
mission_category_id=None,
|
||||
defaults={
|
||||
"merchant_id": notifier.merchant_id,
|
||||
"is_enabled": True,
|
||||
"description": "由旧版 notifier.event_key 自动迁移生成",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("mission", "0004_missioncategory_refactor"),
|
||||
("notifier", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="NotifierRoute",
|
||||
fields=[
|
||||
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="创建时间")),
|
||||
("updated_at", models.DateTimeField(auto_now=True, verbose_name="更新时间")),
|
||||
("id", models.BigAutoField(primary_key=True, serialize=False)),
|
||||
("event_key", models.CharField(choices=[("mission.created", "任务已创建"), ("mission.replied", "任务有新回应"), ("mission.completed", "任务已完成"), ("mission.reply_rejected", "任务回应已撤销"), ("mission.reopened", "任务已重新打开"), ("mission.cancelled", "任务已取消")], db_index=True, max_length=100, verbose_name="事件标识")),
|
||||
("is_enabled", models.BooleanField(db_index=True, default=True, verbose_name="是否启用")),
|
||||
("description", models.TextField(blank=True, null=True, verbose_name="备注描述")),
|
||||
("merchant", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="notifier_routes", to="basic_info.merchant", verbose_name="所属商户")),
|
||||
("mission_category", models.ForeignKey(blank=True, help_text="为空时表示该事件的通配路由", null=True, on_delete=django.db.models.deletion.PROTECT, related_name="notifier_routes", to="mission.missioncategory", verbose_name="任务分类路由")),
|
||||
("notifier", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="routes", to="notifier.notifier", verbose_name="通知器")),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "通知路由",
|
||||
"verbose_name_plural": "通知路由",
|
||||
"indexes": [models.Index(fields=["merchant", "event_key", "is_enabled"], name="notifier_no_merchan_58d463_idx"), models.Index(fields=["merchant", "mission_category", "is_enabled"], name="notifier_no_merchan_14b7f6_idx")],
|
||||
"constraints": [models.UniqueConstraint(fields=("notifier", "event_key", "mission_category"), name="unique_notifier_route_per_scope"), models.UniqueConstraint(condition=Q(mission_category__isnull=True), fields=("notifier", "event_key"), name="unique_notifier_route_global_scope")],
|
||||
},
|
||||
),
|
||||
migrations.RunPython(forwards, migrations.RunPython.noop),
|
||||
migrations.RemoveIndex(
|
||||
model_name="notifier",
|
||||
name="notifier_no_merchan_c59a7a_idx",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="notifier",
|
||||
name="event_key",
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="notifier",
|
||||
index=models.Index(fields=["merchant", "channel", "is_enabled"], name="notifier_no_merchan_532b51_idx"),
|
||||
),
|
||||
]
|
||||
@@ -1,4 +1,6 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
from flower.common import ModelBase
|
||||
|
||||
@@ -25,12 +27,6 @@ class Notifier(ModelBase):
|
||||
verbose_name="所属商户",
|
||||
)
|
||||
name = models.CharField(max_length=100, verbose_name="通知器名称")
|
||||
event_key = models.CharField(
|
||||
max_length=100,
|
||||
choices=NotificationEventKeyEnum.choices,
|
||||
db_index=True,
|
||||
verbose_name="事件标识",
|
||||
)
|
||||
channel = models.CharField(
|
||||
max_length=50,
|
||||
choices=NotifierChannelEnum.choices,
|
||||
@@ -50,7 +46,7 @@ class Notifier(ModelBase):
|
||||
return config.get(key, default)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.event_key})"
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
verbose_name = "通知器"
|
||||
@@ -59,5 +55,70 @@ class Notifier(ModelBase):
|
||||
models.UniqueConstraint(fields=["merchant", "name"], name="unique_notifier_name_per_merchant"),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["merchant", "event_key", "is_enabled"]),
|
||||
models.Index(fields=["merchant", "channel", "is_enabled"], name="notifier_no_merchan_532b51_idx"),
|
||||
]
|
||||
|
||||
|
||||
class NotifierRoute(ModelBase):
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
merchant = models.ForeignKey(
|
||||
"basic_info.Merchant",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="notifier_routes",
|
||||
verbose_name="所属商户",
|
||||
)
|
||||
notifier = models.ForeignKey(
|
||||
Notifier,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="routes",
|
||||
verbose_name="通知器",
|
||||
)
|
||||
event_key = models.CharField(
|
||||
max_length=100,
|
||||
choices=NotificationEventKeyEnum.choices,
|
||||
db_index=True,
|
||||
verbose_name="事件标识",
|
||||
)
|
||||
mission_category = models.ForeignKey(
|
||||
"mission.MissionCategory",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="notifier_routes",
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="任务分类路由",
|
||||
help_text="为空时表示该事件的通配路由",
|
||||
)
|
||||
is_enabled = models.BooleanField(default=True, db_index=True, verbose_name="是否启用")
|
||||
description = models.TextField(blank=True, null=True, verbose_name="备注描述")
|
||||
|
||||
def clean(self):
|
||||
errors = {}
|
||||
if self.notifier_id and self.merchant_id and self.notifier.merchant_id != self.merchant_id:
|
||||
errors["merchant"] = "路由所属商户必须与通知器所属商户一致"
|
||||
if self.mission_category_id and self.merchant_id and self.mission_category.merchant_id != self.merchant_id:
|
||||
errors["mission_category"] = "任务分类必须属于当前路由商户"
|
||||
if errors:
|
||||
raise ValidationError(errors)
|
||||
|
||||
def __str__(self):
|
||||
category_name = self.mission_category.name if self.mission_category_id else "全部分类"
|
||||
return f"{self.notifier.name} -> {self.event_key} [{category_name}]"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "通知路由"
|
||||
verbose_name_plural = "通知路由"
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["notifier", "event_key", "mission_category"],
|
||||
name="unique_notifier_route_per_scope",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["notifier", "event_key"],
|
||||
condition=Q(mission_category__isnull=True),
|
||||
name="unique_notifier_route_global_scope",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["merchant", "event_key", "is_enabled"], name="notifier_no_merchan_58d463_idx"),
|
||||
models.Index(fields=["merchant", "mission_category", "is_enabled"], name="notifier_no_merchan_14b7f6_idx"),
|
||||
]
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import logging
|
||||
|
||||
from django.db.models import Case, IntegerField, Q, Value, When
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
from notifier.models import Notifier
|
||||
from notifier.models import Notifier, NotifierRoute
|
||||
from notifier.registry import get_notifier_backend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -22,7 +23,6 @@ def send_notification_with_notifier(*, notifier: Notifier, payload: dict) -> dic
|
||||
result = {
|
||||
"notifier_id": notifier.id,
|
||||
"notifier_name": notifier.name,
|
||||
"event_key": notifier.event_key,
|
||||
"channel": notifier.channel,
|
||||
"template_key": notifier.template_key,
|
||||
"status": "sent",
|
||||
@@ -32,29 +32,78 @@ def send_notification_with_notifier(*, notifier: Notifier, payload: dict) -> dic
|
||||
return result
|
||||
|
||||
|
||||
def _match_notifier_routes(*, event_key: str, merchant_id: int, payload: dict | None = None) -> list[NotifierRoute]:
|
||||
payload = payload or {}
|
||||
category_id = payload.get("category_id")
|
||||
queryset = NotifierRoute.objects.filter(
|
||||
merchant_id=merchant_id,
|
||||
event_key=event_key,
|
||||
is_enabled=True,
|
||||
notifier__is_enabled=True,
|
||||
).select_related("notifier", "mission_category")
|
||||
|
||||
if category_id is not None:
|
||||
queryset = queryset.filter(Q(mission_category_id=category_id) | Q(mission_category__isnull=True)).annotate(
|
||||
route_priority=Case(
|
||||
When(mission_category_id=category_id, then=Value(0)),
|
||||
default=Value(1),
|
||||
output_field=IntegerField(),
|
||||
)
|
||||
)
|
||||
else:
|
||||
queryset = queryset.filter(mission_category__isnull=True).annotate(
|
||||
route_priority=Value(0, output_field=IntegerField())
|
||||
)
|
||||
|
||||
queryset = queryset.order_by("notifier_id", "route_priority", "id")
|
||||
matched_by_notifier_id = {}
|
||||
for route in queryset:
|
||||
matched_by_notifier_id.setdefault(route.notifier_id, route)
|
||||
return list(matched_by_notifier_id.values())
|
||||
|
||||
|
||||
def dispatch_notification_event(*, event_key: str, merchant_id: int, payload: dict | None = None) -> list[dict]:
|
||||
notifiers = list(
|
||||
Notifier.objects.filter(
|
||||
merchant_id=merchant_id,
|
||||
event_key=event_key,
|
||||
is_enabled=True,
|
||||
).order_by("id")
|
||||
payload = payload or {}
|
||||
routes = _match_notifier_routes(
|
||||
event_key=event_key,
|
||||
merchant_id=merchant_id,
|
||||
payload=payload,
|
||||
)
|
||||
if not notifiers:
|
||||
if not routes:
|
||||
logger.info(
|
||||
"[notifier.services] no enabled notifier matched: event_key=%s merchant_id=%s",
|
||||
"[notifier.services] no enabled notifier route matched: event_key=%s merchant_id=%s category_id=%s",
|
||||
event_key,
|
||||
merchant_id,
|
||||
payload.get("category_id"),
|
||||
)
|
||||
return []
|
||||
|
||||
results = []
|
||||
for notifier in notifiers:
|
||||
for route in routes:
|
||||
notifier = route.notifier
|
||||
try:
|
||||
results.append(send_notification_with_notifier(notifier=notifier, payload=payload or {}))
|
||||
item = send_notification_with_notifier(notifier=notifier, payload=payload)
|
||||
item.update(
|
||||
{
|
||||
"event_key": event_key,
|
||||
"route_id": route.id,
|
||||
"route_event_key": route.event_key,
|
||||
"route_mission_category_id": route.mission_category_id,
|
||||
}
|
||||
)
|
||||
results.append(item)
|
||||
logger.info(
|
||||
"[notifier.services] notification routed: route_id=%s notifier_id=%s event_key=%s merchant_id=%s route_category_id=%s",
|
||||
route.id,
|
||||
notifier.id,
|
||||
event_key,
|
||||
merchant_id,
|
||||
route.mission_category_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"[notifier.services] notification failed: notifier_id=%s event_key=%s merchant_id=%s",
|
||||
"[notifier.services] notification failed: route_id=%s notifier_id=%s event_key=%s merchant_id=%s",
|
||||
route.id,
|
||||
notifier.id,
|
||||
event_key,
|
||||
merchant_id,
|
||||
@@ -63,9 +112,12 @@ def dispatch_notification_event(*, event_key: str, merchant_id: int, payload: di
|
||||
{
|
||||
"notifier_id": notifier.id,
|
||||
"notifier_name": notifier.name,
|
||||
"event_key": notifier.event_key,
|
||||
"event_key": event_key,
|
||||
"channel": notifier.channel,
|
||||
"template_key": notifier.template_key,
|
||||
"route_id": route.id,
|
||||
"route_event_key": route.event_key,
|
||||
"route_mission_category_id": route.mission_category_id,
|
||||
"status": "failed",
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
任务已取消
|
||||
|
||||
任务ID:{{ mission_id }}
|
||||
分类:{{ category_name }}
|
||||
取消人:{{ cancelled_by_name }}
|
||||
取消时间:{{ cancelled_at }}
|
||||
任务描述:{{ description }}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
任务已完成
|
||||
|
||||
任务ID:{{ mission_id }}
|
||||
分类:{{ category_name }}
|
||||
完成人:{{ completed_by_name }}
|
||||
结束回应ID:{{ reply_id|default:"" }}
|
||||
结束回应内容:{{ reply_content|default:"" }}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
任务已重新打开
|
||||
|
||||
任务ID:{{ mission_id }}
|
||||
分类:{{ category_name }}
|
||||
操作人:{{ reopened_by_name }}
|
||||
被撤销的结束回应ID:{{ rejected_reply_ids_display }}
|
||||
任务描述:{{ description }}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
任务有新回应
|
||||
|
||||
任务ID:{{ mission_id }}
|
||||
分类:{{ category_name }}
|
||||
回应ID:{{ reply_id }}
|
||||
回应者:{{ responder_name }}
|
||||
是否结束任务:{% if ends_task %}是{% else %}否{% endif %}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
任务回应已撤销
|
||||
|
||||
任务ID:{{ mission_id }}
|
||||
分类:{{ category_name }}
|
||||
回应ID:{{ reply_id }}
|
||||
撤销人:{{ rejected_by_name }}
|
||||
撤销原因:{{ reason }}
|
||||
|
||||
@@ -3,7 +3,8 @@ from unittest.mock import patch
|
||||
from django.test import TestCase
|
||||
|
||||
from basic_info.models import Merchant, MerchantTypeEnum
|
||||
from notifier.models import NotificationEventKeyEnum, Notifier, NotifierChannelEnum
|
||||
from mission.models import MissionCategory
|
||||
from notifier.models import NotificationEventKeyEnum, Notifier, NotifierChannelEnum, NotifierRoute
|
||||
from notifier.services import (
|
||||
dispatch_notification_event,
|
||||
enqueue_notification_event,
|
||||
@@ -15,14 +16,20 @@ from notifier.services import (
|
||||
class NotifierServiceTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.merchant = Merchant.objects.create(name="通知商户", type=MerchantTypeEnum.STORE)
|
||||
self.general_category = MissionCategory.objects.create(merchant=self.merchant, name="通用")
|
||||
self.after_sale_category = MissionCategory.objects.create(merchant=self.merchant, name="售后")
|
||||
self.notifier = Notifier.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="任务创建通知",
|
||||
event_key=NotificationEventKeyEnum.MISSION_CREATED,
|
||||
channel=NotifierChannelEnum.WECOM_WEBHOOK,
|
||||
template_key="mission_created",
|
||||
config={"key": "abc123", "msgtype": "markdown"},
|
||||
)
|
||||
self.global_route = NotifierRoute.objects.create(
|
||||
merchant=self.merchant,
|
||||
notifier=self.notifier,
|
||||
event_key=NotificationEventKeyEnum.MISSION_CREATED,
|
||||
)
|
||||
|
||||
def test_render_notification_content(self):
|
||||
content = render_notification_content(
|
||||
@@ -66,28 +73,36 @@ class NotifierServiceTestCase(TestCase):
|
||||
mock_send.assert_called_once()
|
||||
|
||||
@patch("notifier.services.send_notification_with_notifier")
|
||||
def test_dispatch_notification_event_filters_by_event_and_enabled(self, mock_send):
|
||||
def test_dispatch_notification_event_filters_by_route_event_and_enabled(self, mock_send):
|
||||
mock_send.side_effect = lambda *, notifier, payload: {
|
||||
"notifier_id": notifier.id,
|
||||
"status": "sent",
|
||||
}
|
||||
Notifier.objects.create(
|
||||
replied_notifier = Notifier.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="任务回应通知",
|
||||
event_key=NotificationEventKeyEnum.MISSION_REPLIED,
|
||||
channel=NotifierChannelEnum.WECOM_WEBHOOK,
|
||||
template_key="mission_replied",
|
||||
config={"key": "def456"},
|
||||
)
|
||||
Notifier.objects.create(
|
||||
NotifierRoute.objects.create(
|
||||
merchant=self.merchant,
|
||||
notifier=replied_notifier,
|
||||
event_key=NotificationEventKeyEnum.MISSION_REPLIED,
|
||||
)
|
||||
disabled_notifier = Notifier.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="停用通知器",
|
||||
event_key=NotificationEventKeyEnum.MISSION_CREATED,
|
||||
channel=NotifierChannelEnum.WECOM_WEBHOOK,
|
||||
template_key="mission_created",
|
||||
is_enabled=False,
|
||||
config={"key": "ghi789"},
|
||||
)
|
||||
NotifierRoute.objects.create(
|
||||
merchant=self.merchant,
|
||||
notifier=disabled_notifier,
|
||||
event_key=NotificationEventKeyEnum.MISSION_CREATED,
|
||||
)
|
||||
|
||||
results = dispatch_notification_event(
|
||||
event_key=NotificationEventKeyEnum.MISSION_CREATED,
|
||||
@@ -95,10 +110,62 @@ class NotifierServiceTestCase(TestCase):
|
||||
payload={"mission_id": 99},
|
||||
)
|
||||
|
||||
self.assertEqual(results, [{"notifier_id": self.notifier.id, "status": "sent"}])
|
||||
self.assertEqual(
|
||||
results,
|
||||
[
|
||||
{
|
||||
"notifier_id": self.notifier.id,
|
||||
"status": "sent",
|
||||
"event_key": NotificationEventKeyEnum.MISSION_CREATED,
|
||||
"route_id": self.global_route.id,
|
||||
"route_event_key": NotificationEventKeyEnum.MISSION_CREATED,
|
||||
"route_mission_category_id": None,
|
||||
}
|
||||
],
|
||||
)
|
||||
self.assertEqual(mock_send.call_count, 1)
|
||||
self.assertEqual(mock_send.call_args.kwargs["notifier"].id, self.notifier.id)
|
||||
|
||||
@patch("notifier.services.send_notification_with_notifier")
|
||||
def test_dispatch_notification_event_prefers_category_specific_route(self, mock_send):
|
||||
mock_send.side_effect = lambda *, notifier, payload: {
|
||||
"notifier_id": notifier.id,
|
||||
"status": "sent",
|
||||
}
|
||||
specific_route = NotifierRoute.objects.create(
|
||||
merchant=self.merchant,
|
||||
notifier=self.notifier,
|
||||
event_key=NotificationEventKeyEnum.MISSION_CREATED,
|
||||
mission_category=self.after_sale_category,
|
||||
)
|
||||
|
||||
results = dispatch_notification_event(
|
||||
event_key=NotificationEventKeyEnum.MISSION_CREATED,
|
||||
merchant_id=self.merchant.id,
|
||||
payload={"mission_id": 99, "category_id": self.after_sale_category.id},
|
||||
)
|
||||
|
||||
self.assertEqual(mock_send.call_count, 1)
|
||||
self.assertEqual(results[0]["route_id"], specific_route.id)
|
||||
self.assertEqual(results[0]["route_mission_category_id"], self.after_sale_category.id)
|
||||
|
||||
@patch("notifier.services.send_notification_with_notifier")
|
||||
def test_dispatch_notification_event_falls_back_to_global_route(self, mock_send):
|
||||
mock_send.side_effect = lambda *, notifier, payload: {
|
||||
"notifier_id": notifier.id,
|
||||
"status": "sent",
|
||||
}
|
||||
|
||||
results = dispatch_notification_event(
|
||||
event_key=NotificationEventKeyEnum.MISSION_CREATED,
|
||||
merchant_id=self.merchant.id,
|
||||
payload={"mission_id": 99, "category_id": self.general_category.id},
|
||||
)
|
||||
|
||||
self.assertEqual(mock_send.call_count, 1)
|
||||
self.assertEqual(results[0]["route_id"], self.global_route.id)
|
||||
self.assertIsNone(results[0]["route_mission_category_id"])
|
||||
|
||||
@patch("notifier.tasks.dispatch_notification_event_task.delay")
|
||||
def test_enqueue_notification_event_returns_task_id(self, mock_delay):
|
||||
mock_delay.return_value.id = "task-123"
|
||||
|
||||
Reference in New Issue
Block a user