forked from erp-dev/erp
feat: mission replied calling task
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("mission", "0004_missioncategory_refactor"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="mission",
|
||||
name="notify_if_unreplied",
|
||||
field=models.BooleanField(db_index=True, default=False, verbose_name="是否开启未回复提醒"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="mission",
|
||||
name="unreplied_last_notified_at",
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name="上一次未回复提醒时间"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="mission",
|
||||
name="unreplied_notify_interval_minutes",
|
||||
field=models.PositiveIntegerField(blank=True, null=True, verbose_name="未回复提醒间隔(分钟)"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="mission",
|
||||
name="unreplied_notify_max_count",
|
||||
field=models.PositiveIntegerField(default=5, verbose_name="未回复最大提醒次数"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="mission",
|
||||
name="unreplied_notify_sent_count",
|
||||
field=models.PositiveIntegerField(default=0, verbose_name="未回复已提醒次数"),
|
||||
),
|
||||
]
|
||||
@@ -50,6 +50,19 @@ class Mission(ModelBase):
|
||||
is_urgent = models.BooleanField(default=False, db_index=True, verbose_name="是否紧急")
|
||||
is_completed = models.BooleanField(default=False, db_index=True, verbose_name="是否完成")
|
||||
is_cancelled = models.BooleanField(default=False, db_index=True, verbose_name="是否取消")
|
||||
notify_if_unreplied = models.BooleanField(default=False, db_index=True, verbose_name="是否开启未回复提醒")
|
||||
unreplied_notify_interval_minutes = models.PositiveIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="未回复提醒间隔(分钟)",
|
||||
)
|
||||
unreplied_notify_max_count = models.PositiveIntegerField(default=5, verbose_name="未回复最大提醒次数")
|
||||
unreplied_notify_sent_count = models.PositiveIntegerField(default=0, verbose_name="未回复已提醒次数")
|
||||
unreplied_last_notified_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="上一次未回复提醒时间",
|
||||
)
|
||||
cancelled_at = models.DateTimeField(null=True, blank=True, verbose_name="取消时间")
|
||||
creator = models.ForeignKey(
|
||||
"basic_info.Employee",
|
||||
@@ -84,6 +97,10 @@ class Mission(ModelBase):
|
||||
def can_reply(self) -> bool:
|
||||
return not self.is_cancelled and not self.has_ending_reply
|
||||
|
||||
@property
|
||||
def has_effective_reply(self) -> bool:
|
||||
return self.replies.filter(is_rejected=False).exists()
|
||||
|
||||
def get_participants(self):
|
||||
return self.participants.select_related("employee")
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
@@ -16,6 +17,7 @@ from mission.signals import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_MISSION_CATEGORY_NAME = "通用"
|
||||
UNSET = object()
|
||||
|
||||
|
||||
def _send_signal_on_commit(signal, *, sender, **payload) -> None:
|
||||
@@ -66,6 +68,41 @@ def _validate_content_object_merchant(*, content_type, content_id, merchant) ->
|
||||
raise ValueError("关联业务对象不属于当前商户")
|
||||
|
||||
|
||||
def _validate_unreplied_notification_config(
|
||||
*,
|
||||
notify_if_unreplied: bool,
|
||||
unreplied_notify_interval_minutes: int | None,
|
||||
unreplied_notify_max_count: int,
|
||||
) -> None:
|
||||
if notify_if_unreplied and unreplied_notify_interval_minutes is None:
|
||||
raise ValueError("开启未回复提醒时必须设置提醒间隔")
|
||||
if unreplied_notify_interval_minutes is not None and unreplied_notify_interval_minutes <= 0:
|
||||
raise ValueError("未回复提醒间隔必须大于 0")
|
||||
if unreplied_notify_max_count <= 0:
|
||||
raise ValueError("未回复最大提醒次数必须大于 0")
|
||||
|
||||
|
||||
def _reset_unreplied_notification_state(mission: Mission) -> None:
|
||||
mission.unreplied_notify_sent_count = 0
|
||||
mission.unreplied_last_notified_at = None
|
||||
|
||||
|
||||
def _has_effective_reply(*, mission: Mission) -> bool:
|
||||
return mission.replies.filter(is_rejected=False).exists()
|
||||
|
||||
|
||||
def _clear_unreplied_notification_state_if_active(*, mission: Mission) -> None:
|
||||
if mission.unreplied_notify_sent_count or mission.unreplied_last_notified_at is not None:
|
||||
_reset_unreplied_notification_state(mission)
|
||||
mission.save(
|
||||
update_fields=[
|
||||
"unreplied_notify_sent_count",
|
||||
"unreplied_last_notified_at",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def set_mission_participants(*, mission: Mission, participant_ids: list[int]) -> Mission:
|
||||
mission = Mission.objects.select_for_update().get(pk=mission.pk)
|
||||
@@ -101,12 +138,20 @@ def create_mission(
|
||||
content_type=None,
|
||||
content_id: int | None = None,
|
||||
participant_ids: list[int] | None = None,
|
||||
notify_if_unreplied: bool = False,
|
||||
unreplied_notify_interval_minutes: int | None = None,
|
||||
unreplied_notify_max_count: int = 5,
|
||||
) -> Mission:
|
||||
if creator is None:
|
||||
raise ValueError("任务创建者不能为空")
|
||||
merchant = creator.merchant
|
||||
_validate_content_object_merchant(content_type=content_type, content_id=content_id, merchant=merchant)
|
||||
_assert_category_belongs_to_merchant(category, merchant=merchant)
|
||||
_validate_unreplied_notification_config(
|
||||
notify_if_unreplied=notify_if_unreplied,
|
||||
unreplied_notify_interval_minutes=unreplied_notify_interval_minutes,
|
||||
unreplied_notify_max_count=unreplied_notify_max_count,
|
||||
)
|
||||
|
||||
mission = Mission.objects.create(
|
||||
merchant=merchant,
|
||||
@@ -115,6 +160,9 @@ def create_mission(
|
||||
category=category or _get_default_mission_category(merchant=merchant),
|
||||
content_type=content_type,
|
||||
content_id=content_id,
|
||||
notify_if_unreplied=notify_if_unreplied,
|
||||
unreplied_notify_interval_minutes=unreplied_notify_interval_minutes,
|
||||
unreplied_notify_max_count=unreplied_notify_max_count,
|
||||
)
|
||||
if participant_ids is not None:
|
||||
set_mission_participants(mission=mission, participant_ids=participant_ids)
|
||||
@@ -138,6 +186,9 @@ def update_mission(
|
||||
content_id: int | None = None,
|
||||
update_content_object: bool = False,
|
||||
participant_ids: list[int] | None = None,
|
||||
notify_if_unreplied=UNSET,
|
||||
unreplied_notify_interval_minutes=UNSET,
|
||||
unreplied_notify_max_count=UNSET,
|
||||
) -> Mission:
|
||||
mission = Mission.objects.select_for_update().get(pk=mission.pk)
|
||||
_assert_employee_belongs_to_mission(updated_by, mission, "更新任务的员工")
|
||||
@@ -160,8 +211,55 @@ def update_mission(
|
||||
mission.content_id = content_id
|
||||
update_fields.extend(["content_type", "content_id"])
|
||||
|
||||
notify_changed = notify_if_unreplied is not UNSET
|
||||
interval_changed = unreplied_notify_interval_minutes is not UNSET
|
||||
max_count_changed = unreplied_notify_max_count is not UNSET
|
||||
if notify_changed or interval_changed or max_count_changed:
|
||||
final_notify_if_unreplied = (
|
||||
notify_if_unreplied
|
||||
if notify_changed
|
||||
else mission.notify_if_unreplied
|
||||
)
|
||||
final_interval = (
|
||||
unreplied_notify_interval_minutes
|
||||
if interval_changed
|
||||
else mission.unreplied_notify_interval_minutes
|
||||
)
|
||||
final_max_count = (
|
||||
unreplied_notify_max_count
|
||||
if max_count_changed
|
||||
else mission.unreplied_notify_max_count
|
||||
)
|
||||
_validate_unreplied_notification_config(
|
||||
notify_if_unreplied=final_notify_if_unreplied,
|
||||
unreplied_notify_interval_minutes=final_interval,
|
||||
unreplied_notify_max_count=final_max_count,
|
||||
)
|
||||
if notify_changed:
|
||||
mission.notify_if_unreplied = final_notify_if_unreplied
|
||||
update_fields.append("notify_if_unreplied")
|
||||
if interval_changed:
|
||||
mission.unreplied_notify_interval_minutes = final_interval
|
||||
update_fields.append("unreplied_notify_interval_minutes")
|
||||
if max_count_changed:
|
||||
mission.unreplied_notify_max_count = final_max_count
|
||||
update_fields.append("unreplied_notify_max_count")
|
||||
|
||||
if not final_notify_if_unreplied:
|
||||
_reset_unreplied_notification_state(mission)
|
||||
update_fields.extend([
|
||||
"unreplied_notify_sent_count",
|
||||
"unreplied_last_notified_at",
|
||||
])
|
||||
elif notify_changed or interval_changed:
|
||||
_reset_unreplied_notification_state(mission)
|
||||
update_fields.extend([
|
||||
"unreplied_notify_sent_count",
|
||||
"unreplied_last_notified_at",
|
||||
])
|
||||
|
||||
if update_fields:
|
||||
mission.save(update_fields=[*update_fields, "updated_at"])
|
||||
mission.save(update_fields=[*dict.fromkeys(update_fields), "updated_at"])
|
||||
if participant_ids is not None:
|
||||
set_mission_participants(mission=mission, participant_ids=participant_ids)
|
||||
return mission
|
||||
@@ -189,7 +287,15 @@ def create_mission_reply(
|
||||
)
|
||||
if ends_task and not mission.is_completed:
|
||||
mission.is_completed = True
|
||||
mission.save(update_fields=["is_completed", "updated_at"])
|
||||
_reset_unreplied_notification_state(mission)
|
||||
mission.save(
|
||||
update_fields=[
|
||||
"is_completed",
|
||||
"unreplied_notify_sent_count",
|
||||
"unreplied_last_notified_at",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
_send_signal_on_commit(
|
||||
mission_completed,
|
||||
sender=Mission,
|
||||
@@ -197,6 +303,8 @@ def create_mission_reply(
|
||||
completed_by=responder,
|
||||
reply=reply,
|
||||
)
|
||||
else:
|
||||
_clear_unreplied_notification_state_if_active(mission=mission)
|
||||
_send_signal_on_commit(
|
||||
mission_replied,
|
||||
sender=MissionReply,
|
||||
@@ -223,7 +331,18 @@ def reopen_mission(*, mission: Mission, reopened_by) -> Mission:
|
||||
now = timezone.now()
|
||||
ending_replies.update(ends_task=False, is_rejected=True, rejected_by=reopened_by, rejected_at=now)
|
||||
mission.is_completed = False
|
||||
mission.save(update_fields=["is_completed", "updated_at"])
|
||||
if mission.notify_if_unreplied and not _has_effective_reply(mission=mission):
|
||||
_reset_unreplied_notification_state(mission)
|
||||
mission.save(
|
||||
update_fields=[
|
||||
"is_completed",
|
||||
"unreplied_notify_sent_count",
|
||||
"unreplied_last_notified_at",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
else:
|
||||
mission.save(update_fields=["is_completed", "updated_at"])
|
||||
for reply in MissionReply.objects.filter(id__in=rejected_reply_ids):
|
||||
_send_signal_on_commit(
|
||||
mission_reply_rejected,
|
||||
@@ -263,9 +382,23 @@ def reject_reply(*, reply: MissionReply, rejected_by) -> MissionReply:
|
||||
reply.save(update_fields=["ends_task", "is_rejected", "rejected_by", "rejected_at", "updated_at"])
|
||||
|
||||
has_other_ending_reply = mission.replies.exclude(pk=reply.pk).filter(ends_task=True, is_rejected=False).exists()
|
||||
has_effective_reply = mission.replies.exclude(pk=reply.pk).filter(is_rejected=False).exists()
|
||||
if was_ending_reply and not has_other_ending_reply and mission.is_completed:
|
||||
mission.is_completed = False
|
||||
mission.save(update_fields=["is_completed", "updated_at"])
|
||||
if mission.notify_if_unreplied and not has_effective_reply:
|
||||
_reset_unreplied_notification_state(mission)
|
||||
mission.save(
|
||||
update_fields=[
|
||||
"is_completed",
|
||||
"unreplied_notify_sent_count",
|
||||
"unreplied_last_notified_at",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
else:
|
||||
mission.save(update_fields=["is_completed", "updated_at"])
|
||||
elif mission.notify_if_unreplied and not has_effective_reply:
|
||||
_clear_unreplied_notification_state_if_active(mission=mission)
|
||||
_send_signal_on_commit(
|
||||
mission_reply_rejected,
|
||||
sender=MissionReply,
|
||||
@@ -288,7 +421,17 @@ def cancel_mission(*, mission: Mission, cancelled_by) -> Mission:
|
||||
mission.is_cancelled = True
|
||||
mission.cancelled_by = cancelled_by
|
||||
mission.cancelled_at = timezone.now()
|
||||
mission.save(update_fields=["is_cancelled", "cancelled_by", "cancelled_at", "updated_at"])
|
||||
_reset_unreplied_notification_state(mission)
|
||||
mission.save(
|
||||
update_fields=[
|
||||
"is_cancelled",
|
||||
"cancelled_by",
|
||||
"cancelled_at",
|
||||
"unreplied_notify_sent_count",
|
||||
"unreplied_last_notified_at",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
_send_signal_on_commit(
|
||||
mission_cancelled,
|
||||
sender=Mission,
|
||||
@@ -306,3 +449,10 @@ def set_mission_urgent(*, mission: Mission, updated_by, is_urgent: bool) -> Miss
|
||||
mission.is_urgent = is_urgent
|
||||
mission.save(update_fields=["is_urgent", "updated_at"])
|
||||
return mission
|
||||
|
||||
|
||||
def get_mission_unreplied_due_at(*, mission: Mission):
|
||||
if mission.unreplied_notify_interval_minutes is None:
|
||||
return None
|
||||
base_time = mission.unreplied_last_notified_at or mission.created_at
|
||||
return base_time + timedelta(minutes=mission.unreplied_notify_interval_minutes)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.test import TestCase
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from unittest.mock import patch
|
||||
from django.utils import timezone
|
||||
|
||||
from basic_info.models import Employee, Merchant, MerchantTypeEnum
|
||||
from mission.models import Mission, MissionCategory, MissionParticipant, MissionReply
|
||||
@@ -160,6 +161,28 @@ class MissionModelTestCase(TestCase):
|
||||
self.assertFalse(mission.is_urgent)
|
||||
self.assertEqual(list(mission.participants.values_list("employee_id", flat=True)), [self.responder.id])
|
||||
|
||||
def test_create_mission_supports_unreplied_notification_fields(self):
|
||||
mission = create_mission(
|
||||
creator=self.creator,
|
||||
description="带未回复提醒的任务",
|
||||
notify_if_unreplied=True,
|
||||
unreplied_notify_interval_minutes=15,
|
||||
)
|
||||
|
||||
self.assertTrue(mission.notify_if_unreplied)
|
||||
self.assertEqual(mission.unreplied_notify_interval_minutes, 15)
|
||||
self.assertEqual(mission.unreplied_notify_max_count, 5)
|
||||
self.assertEqual(mission.unreplied_notify_sent_count, 0)
|
||||
self.assertIsNone(mission.unreplied_last_notified_at)
|
||||
|
||||
def test_create_mission_requires_interval_when_unreplied_notification_enabled(self):
|
||||
with self.assertRaisesMessage(ValueError, "开启未回复提醒时必须设置提醒间隔"):
|
||||
create_mission(
|
||||
creator=self.creator,
|
||||
description="缺少提醒间隔",
|
||||
notify_if_unreplied=True,
|
||||
)
|
||||
|
||||
def test_create_mission_rejects_empty_creator(self):
|
||||
with self.assertRaises(ValueError):
|
||||
create_mission(creator=None, description="无创建人")
|
||||
@@ -222,6 +245,20 @@ class MissionModelTestCase(TestCase):
|
||||
self.assertEqual(self.mission.content_id, related.id)
|
||||
self.assertEqual(list(self.mission.participants.values_list("employee_id", flat=True)), [self.responder.id])
|
||||
|
||||
def test_update_mission_updates_unreplied_notification_fields(self):
|
||||
update_mission(
|
||||
mission=self.mission,
|
||||
updated_by=self.creator,
|
||||
notify_if_unreplied=True,
|
||||
unreplied_notify_interval_minutes=20,
|
||||
unreplied_notify_max_count=8,
|
||||
)
|
||||
|
||||
self.mission.refresh_from_db()
|
||||
self.assertTrue(self.mission.notify_if_unreplied)
|
||||
self.assertEqual(self.mission.unreplied_notify_interval_minutes, 20)
|
||||
self.assertEqual(self.mission.unreplied_notify_max_count, 8)
|
||||
|
||||
def test_create_mission_rejects_cross_merchant_category(self):
|
||||
with self.assertRaises(ValueError):
|
||||
create_mission(
|
||||
@@ -340,6 +377,57 @@ class MissionModelTestCase(TestCase):
|
||||
self.mission.refresh_from_db()
|
||||
self.assertTrue(self.mission.is_completed)
|
||||
|
||||
def test_create_reply_resets_unreplied_notification_state(self):
|
||||
self.mission.notify_if_unreplied = True
|
||||
self.mission.unreplied_notify_interval_minutes = 10
|
||||
self.mission.unreplied_notify_sent_count = 3
|
||||
self.mission.unreplied_last_notified_at = timezone.now()
|
||||
self.mission.save(
|
||||
update_fields=[
|
||||
"notify_if_unreplied",
|
||||
"unreplied_notify_interval_minutes",
|
||||
"unreplied_notify_sent_count",
|
||||
"unreplied_last_notified_at",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
|
||||
create_mission_reply(
|
||||
mission=self.mission,
|
||||
responder=self.responder,
|
||||
content="收到,处理中",
|
||||
)
|
||||
|
||||
self.mission.refresh_from_db()
|
||||
self.assertEqual(self.mission.unreplied_notify_sent_count, 0)
|
||||
self.assertIsNone(self.mission.unreplied_last_notified_at)
|
||||
|
||||
def test_reject_reply_without_other_effective_reply_resets_unreplied_notification_state(self):
|
||||
self.mission.notify_if_unreplied = True
|
||||
self.mission.unreplied_notify_interval_minutes = 10
|
||||
self.mission.unreplied_notify_sent_count = 2
|
||||
self.mission.unreplied_last_notified_at = timezone.now()
|
||||
self.mission.save(
|
||||
update_fields=[
|
||||
"notify_if_unreplied",
|
||||
"unreplied_notify_interval_minutes",
|
||||
"unreplied_notify_sent_count",
|
||||
"unreplied_last_notified_at",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
reply = create_mission_reply(
|
||||
mission=self.mission,
|
||||
responder=self.responder,
|
||||
content="普通回应",
|
||||
)
|
||||
|
||||
reject_reply(reply=reply, rejected_by=self.creator)
|
||||
|
||||
self.mission.refresh_from_db()
|
||||
self.assertEqual(self.mission.unreplied_notify_sent_count, 0)
|
||||
self.assertIsNone(self.mission.unreplied_last_notified_at)
|
||||
|
||||
def test_cancel_already_cancelled_mission_is_rejected(self):
|
||||
cancel_mission(mission=self.mission, cancelled_by=self.creator)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user