Skip to content

Commit 5aa4750

Browse files
committed
feat: 개별 재발송도 가능하게 수정
1 parent 3dab815 commit 5aa4750

7 files changed

Lines changed: 197 additions & 19 deletions

File tree

app/admin_api/serializers/notification.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@
1414
NHNCloudSMSNotificationHistorySentTo,
1515
NHNCloudSMSNotificationTemplate,
1616
)
17-
from notification.models.base import NotificationHistoryBase, NotificationTemplateBase, UnhandledVariableHandling
17+
from notification.models.base import (
18+
NotificationHistoryBase,
19+
NotificationStatus,
20+
NotificationTemplateBase,
21+
UnhandledVariableHandling,
22+
)
1823
from rest_framework import serializers
1924

2025
# ---- SentTo nested ----------------------------------------------------------
@@ -79,11 +84,11 @@ def create(self, validated_data: dict[str, Any]) -> NotificationHistoryBase:
7984
history.refresh_from_db()
8085
return history
8186

82-
def retry(self) -> None:
87+
def retry(self, statuses: list[NotificationStatus], sent_to_id: str | None = None) -> None:
8388
if not (self.instance and self.instance.pk):
8489
raise ValueError("인스턴스가 저장된 후에만 retry할 수 있습니다.")
8590

86-
self.instance.retry()
91+
self.instance.retry(sent_to_id=sent_to_id, statuses=statuses)
8792
self.instance.refresh_from_db()
8893

8994

@@ -169,3 +174,14 @@ class Meta(_NotiTemplateAdminSerializerBase.Meta):
169174

170175
class NotificationTemplateRenderRequestAdminSerializer(serializers.Serializer):
171176
context = serializers.JSONField(required=False, default=dict)
177+
178+
179+
# ---- Query params -----------------------------------------------------------
180+
181+
182+
class NotificationHistoryRetryRequestAdminSerializer(serializers.Serializer):
183+
status = serializers.ListField(
184+
child=serializers.ChoiceField(choices=NotificationStatus.choices),
185+
required=False,
186+
default=[NotificationStatus.FAILED],
187+
)

app/admin_api/test/notification_test.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,139 @@ def test_retry_noop_when_no_failed_sent_to(api_client, email_history):
427427
mock_client.send_message.assert_not_called()
428428

429429

430+
# ---- History Retry SentTo ---------------------------------------------------
431+
432+
433+
@pytest.mark.django_db(transaction=True)
434+
def test_retry_sent_to_only_resends_specified_sent_to(api_client, email_history, email_template):
435+
# 같은 history에 FAILED sent_to가 여러 개 있어도 retry_sent_to는 지정된 1건만 재시도.
436+
extra = EmailNotificationHistory.objects.create_for_recipients(
437+
template=email_template,
438+
recipients=[
439+
{"recipient": "a@example.com", "context": {"name": "a", "recipient": "x"}},
440+
{"recipient": "b@example.com", "context": {"name": "b", "recipient": "x"}},
441+
],
442+
)
443+
extra.sent_to_list.update(status=NotificationStatus.FAILED)
444+
target = extra.sent_to_list.order_by("recipient").first()
445+
446+
with patch("notification.models.email.EmailNotificationHistory.client") as mock_client:
447+
response = api_client.post(
448+
reverse(
449+
"v1:admin-notification-email-history-retry-sent-to",
450+
kwargs={"pk": extra.id, "sent_to_id": target.id},
451+
)
452+
)
453+
assert response.status_code == http.HTTPStatus.OK
454+
assert mock_client.send_message.call_count == 1
455+
target.refresh_from_db()
456+
assert target.status == NotificationStatus.SENT
457+
other = extra.sent_to_list.exclude(pk=target.pk).get()
458+
assert other.status == NotificationStatus.FAILED
459+
460+
461+
@pytest.mark.django_db(transaction=True)
462+
def test_retry_sent_to_404_when_status_not_in_filter(api_client, email_history):
463+
# 지정된 sent_to의 status가 요청 status에 포함되지 않으면 404.
464+
sent_to = email_history.sent_to_list.get() # 기본 status는 CREATED, default 필터는 [FAILED].
465+
with patch("notification.models.email.EmailNotificationHistory.client") as mock_client:
466+
response = api_client.post(
467+
reverse(
468+
"v1:admin-notification-email-history-retry-sent-to",
469+
kwargs={"pk": email_history.id, "sent_to_id": sent_to.id},
470+
)
471+
)
472+
assert response.status_code == http.HTTPStatus.NOT_FOUND
473+
mock_client.send_message.assert_not_called()
474+
475+
476+
@pytest.mark.django_db(transaction=True)
477+
def test_retry_with_status_query_resends_matching_statuses(api_client, email_template):
478+
# ?status=CREATED&status=FAILED → 둘 다 재발송, SENT는 제외.
479+
history = EmailNotificationHistory.objects.create_for_recipients(
480+
template=email_template,
481+
recipients=[
482+
{"recipient": "a@example.com", "context": {"name": "a", "recipient": "x"}},
483+
{"recipient": "b@example.com", "context": {"name": "b", "recipient": "x"}},
484+
{"recipient": "c@example.com", "context": {"name": "c", "recipient": "x"}},
485+
],
486+
)
487+
by_recipient = {s.recipient: s for s in history.sent_to_list.all()}
488+
history.sent_to_list.filter(pk=by_recipient["a@example.com"].pk).update(status=NotificationStatus.SENT)
489+
history.sent_to_list.filter(pk=by_recipient["b@example.com"].pk).update(status=NotificationStatus.FAILED)
490+
# c@는 CREATED 그대로
491+
492+
with patch("notification.models.email.EmailNotificationHistory.client") as mock_client:
493+
response = api_client.post(
494+
reverse("v1:admin-notification-email-history-retry", kwargs={"pk": history.id})
495+
+ "?status=CREATED&status=FAILED"
496+
)
497+
assert response.status_code == http.HTTPStatus.OK
498+
assert mock_client.send_message.call_count == 2
499+
assert history.sent_to_list.get(pk=by_recipient["a@example.com"].pk).status == NotificationStatus.SENT
500+
501+
502+
@pytest.mark.django_db(transaction=True)
503+
def test_retry_with_status_query_force_resends_sent(api_client, email_template):
504+
# ?status=SENT → admin retry 경로는 task 가드를 우회해 실제 재발송이 일어남.
505+
history = EmailNotificationHistory.objects.create_for_recipients(
506+
template=email_template,
507+
recipients=[{"recipient": "a@example.com", "context": {"name": "a", "recipient": "x"}}],
508+
)
509+
history.sent_to_list.update(status=NotificationStatus.SENT)
510+
511+
with patch("notification.models.email.EmailNotificationHistory.client") as mock_client:
512+
response = api_client.post(
513+
reverse("v1:admin-notification-email-history-retry", kwargs={"pk": history.id}) + "?status=SENT"
514+
)
515+
assert response.status_code == http.HTTPStatus.OK
516+
assert mock_client.send_message.call_count == 1
517+
518+
519+
@pytest.mark.django_db
520+
def test_retry_with_invalid_status_query_returns_400(api_client, email_history):
521+
response = api_client.post(
522+
reverse("v1:admin-notification-email-history-retry", kwargs={"pk": email_history.id}) + "?status=BOGUS"
523+
)
524+
assert response.status_code == http.HTTPStatus.BAD_REQUEST
525+
526+
527+
@pytest.mark.django_db(transaction=True)
528+
def test_retry_sent_to_with_status_query_respects_filter(api_client, email_history):
529+
# sent_to_id 대상의 status가 query에 포함되면 재시도, 아니면 404.
530+
sent_to = email_history.sent_to_list.get() # 기본 status는 CREATED.
531+
url = reverse(
532+
"v1:admin-notification-email-history-retry-sent-to",
533+
kwargs={"pk": email_history.id, "sent_to_id": sent_to.id},
534+
)
535+
536+
with patch("notification.models.email.EmailNotificationHistory.client") as mock_client:
537+
# CREATED가 query에 없으면 404
538+
response = api_client.post(url + "?status=FAILED")
539+
assert response.status_code == http.HTTPStatus.NOT_FOUND
540+
assert mock_client.send_message.call_count == 0
541+
# CREATED 포함하면 발송 O
542+
response = api_client.post(url + "?status=CREATED&status=FAILED")
543+
assert response.status_code == http.HTTPStatus.OK
544+
assert mock_client.send_message.call_count == 1
545+
546+
547+
@pytest.mark.django_db
548+
def test_retry_sent_to_404_when_sent_to_belongs_to_other_history(api_client, email_history, email_template):
549+
other = EmailNotificationHistory.objects.create_for_recipients(
550+
template=email_template,
551+
recipients=[{"recipient": "other@example.com", "context": {"name": "다른", "recipient": "y"}}],
552+
)
553+
other_sent_to = other.sent_to_list.get()
554+
response = api_client.post(
555+
reverse(
556+
"v1:admin-notification-email-history-retry-sent-to",
557+
kwargs={"pk": email_history.id, "sent_to_id": other_sent_to.id},
558+
)
559+
)
560+
assert response.status_code == http.HTTPStatus.NOT_FOUND
561+
562+
430563
# ---- History Render SentTo As HTML -----------------------------------------
431564

432565

app/admin_api/views/notification.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
NHNCloudKakaoAlimTalkNotificationTemplateAdminSerializer,
99
NHNCloudSMSNotificationHistoryAdminSerializer,
1010
NHNCloudSMSNotificationTemplateAdminSerializer,
11+
NotificationHistoryRetryRequestAdminSerializer,
1112
NotificationTemplateRenderRequestAdminSerializer,
1213
)
1314
from core.const.tag import OpenAPITag
@@ -33,7 +34,7 @@
3334

3435
TEMPLATE_READ_METHODS = ["list", "retrieve", "render_preview"]
3536
TEMPLATE_CRUD_METHODS = TEMPLATE_READ_METHODS + ["create", "update", "partial_update", "destroy"]
36-
HISTORY_METHODS = ["list", "retrieve", "create", "retry", "render_sent_to_as_html"]
37+
HISTORY_METHODS = ["list", "retrieve", "create", "retry", "retry_sent_to", "render_sent_to_as_html"]
3738

3839

3940
# ---- Template -----------------------------------------------------------
@@ -91,9 +92,23 @@ class _NotiHistoryAdminViewSetBase(CreateModelMixin, ListModelMixin, RetrieveMod
9192
filterset_class = NotificationHistoryAdminFilterSet
9293

9394
@action(detail=True, methods=["post"], url_path="retry")
94-
def retry(self, *args: tuple, **kwargs: dict) -> Response:
95+
def retry(self, request: Request, *args: tuple, **kwargs: dict) -> Response:
96+
query = NotificationHistoryRetryRequestAdminSerializer(data=request.query_params)
97+
query.is_valid(raise_exception=True)
98+
9599
serializer = self.get_serializer(instance=self.get_object())
96-
serializer.retry()
100+
serializer.retry(statuses=query.validated_data["status"])
101+
return Response(data=serializer.data)
102+
103+
@action(detail=True, methods=["post"], url_path=r"sent-to/(?P<sent_to_id>[^/.]+)/retry")
104+
def retry_sent_to(self, request: Request, sent_to_id: str, *args: tuple, **kwargs: dict) -> Response:
105+
query = NotificationHistoryRetryRequestAdminSerializer(data=request.query_params)
106+
query.is_valid(raise_exception=True)
107+
108+
history = self.get_object()
109+
get_object_or_404(history.sent_to_list.all(), pk=sent_to_id, status__in=query.validated_data["status"])
110+
serializer = self.get_serializer(instance=history)
111+
serializer.retry(statuses=query.validated_data["status"], sent_to_id=sent_to_id)
97112
return Response(data=serializer.data)
98113

99114
@extend_schema(responses=build_html_responses(names=["Notification History SentTo Render As HTML"]))

app/notification/models/base.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -171,19 +171,22 @@ def sent_to_status_summary(self) -> dict[str, int]:
171171
return {status.value.lower(): counts.get(status.value, 0) for status in NotificationStatus}
172172

173173
@transaction.atomic
174-
def _dispatch(self, sent_to_qs: "models.QuerySet[NotificationHistorySentToBase]") -> None:
174+
def _dispatch(self, sent_to_qs: "models.QuerySet[NotificationHistorySentToBase]", *, force: bool = False) -> None:
175175
from notification.tasks import send_notification_to_recipient
176176

177-
if not (sent_to_ids := list(sent_to_qs.values_list("id", flat=True))):
177+
if not (ids := list(sent_to_qs.values_list("id", flat=True))):
178178
return
179179
label = type(self).sent_to_class._meta.label_lower
180-
transaction.on_commit(lambda: [send_notification_to_recipient.delay(label, sid) for sid in sent_to_ids])
180+
transaction.on_commit(lambda: [send_notification_to_recipient.delay(label, sid, force=force) for sid in ids])
181181

182182
def send(self) -> None:
183183
self._dispatch(self.sent_to_list.all())
184184

185-
def retry(self) -> None:
186-
self._dispatch(self.sent_to_list.filter(status=NotificationStatus.FAILED))
185+
def retry(self, statuses: list[NotificationStatus], sent_to_id: str | None = None) -> None:
186+
qs = self.sent_to_list.filter(status__in=statuses)
187+
if sent_to_id is not None:
188+
qs = qs.filter(pk=sent_to_id)
189+
self._dispatch(qs, force=True)
187190

188191

189192
class NotificationHistorySentToBase(BaseAbstractModel):

app/notification/tasks.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66

77

88
@shared_task(ignore_result=True)
9-
def send_notification_to_recipient(model_label: str, sent_to_id: str) -> None:
9+
def send_notification_to_recipient(model_label: str, sent_to_id: str, force: bool = False) -> None:
1010
sent_to_class = apps.get_model(model_label)
1111
sent_to = sent_to_class.objects.select_related("history").get(pk=sent_to_id)
12-
if sent_to.status not in (NotificationStatus.CREATED, NotificationStatus.FAILED):
12+
if not force and sent_to.status not in (NotificationStatus.CREATED, NotificationStatus.FAILED):
1313
return
1414

1515
try:

app/notification/test/history_send_test.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -201,21 +201,21 @@ def test_history_send_logs_unexpected_errors_outside_inner_try(email_template, c
201201

202202

203203
@pytest.mark.django_db(transaction=True)
204-
def test_history_retry_skips_non_failed_sent_to(email_template):
205-
# FAILED 상태가 아닌 sent_to는 재시도 대상에서 제외 — 외부 호출이 발생하지 않음.
206-
history = _create_history(email_template)
204+
def test_history_retry_skips_non_matching_status(email_template):
205+
# statuses에 포함되지 않는 status의 sent_to는 재시도 대상에서 제외 — 외부 호출이 발생하지 않음.
206+
history = _create_history(email_template) # 기본 status는 CREATED.
207207
with patch.object(EmailNotificationHistory, "client") as mock_client:
208-
history.retry()
208+
history.retry(statuses=[NotificationStatus.FAILED])
209209
mock_client.send_message.assert_not_called()
210210

211211

212212
@pytest.mark.django_db(transaction=True)
213-
def test_history_retry_resends_failed_sent_to(email_template):
213+
def test_history_retry_resends_matching_status(email_template):
214214
history = _create_history(email_template)
215215
history.sent_to_list.update(status=NotificationStatus.FAILED)
216216

217217
with patch.object(EmailNotificationHistory, "client"):
218-
history.retry()
218+
history.retry(statuses=[NotificationStatus.FAILED])
219219
sent_to = history.sent_to_list.get()
220220
assert sent_to.status == NotificationStatus.SENT
221221

app/notification/test/tasks_test.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,17 @@ def test_task_skips_sent_row_to_prevent_duplicate_send(sent_to):
7676
mock_client.send_message.assert_not_called()
7777

7878

79+
@pytest.mark.django_db
80+
def test_task_force_bypasses_status_guard(sent_to):
81+
sent_to.status = NotificationStatus.SENT
82+
sent_to.save(update_fields=["status"])
83+
with patch.object(EmailNotificationHistory, "client") as mock_client:
84+
send_notification_to_recipient(LABEL, sent_to.id, force=True)
85+
mock_client.send_message.assert_called_once()
86+
sent_to.refresh_from_db()
87+
assert sent_to.status == NotificationStatus.SENT
88+
89+
7990
@pytest.mark.django_db
8091
def test_task_marks_failed_and_propagates_external_failure(sent_to):
8192
with patch.object(EmailNotificationHistory, "client") as mock_client:

0 commit comments

Comments
 (0)