Skip to content

Commit 3dab815

Browse files
MU-SoftwareCopilot
andcommitted
feat: 알림 전송 실패 시 실패 사유를 기록하여 API에서 볼 수 있게 함 + 각 send_to에 대한 렌더링 결과를 얻을 수 있도록 API 추가
Co-authored-by: Copilot <copilot@github.com>
1 parent 6b48205 commit 3dab815

8 files changed

Lines changed: 164 additions & 5 deletions

File tree

app/admin_api/serializers/notification.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@
2222

2323
class _NotiHistorySentToAdminSerializerBase(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer):
2424
class Meta:
25-
fields = COMMON_ADMIN_FIELDS + ("recipient", "context", "status")
26-
read_only_fields = (*COMMON_ADMIN_FIELDS, "status")
25+
fields = COMMON_ADMIN_FIELDS + ("recipient", "context", "status", "failure_reason")
26+
read_only_fields = (*COMMON_ADMIN_FIELDS, "status", "failure_reason")
2727

2828

2929
class EmailNotificationHistorySentToAdminSerializer(_NotiHistorySentToAdminSerializerBase):

app/admin_api/test/notification_test.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ def test_create_history_marks_sent_to_failed_when_send_raises(api_client, sms_te
265265
body = response.json()
266266
assert body["sent_to_status_summary"]["failed"] == 1
267267
assert body["sent_to_list"][0]["status"] == NotificationStatus.FAILED
268+
assert "external api down" in body["sent_to_list"][0]["failure_reason"]
268269

269270

270271
@pytest.mark.django_db(transaction=True)
@@ -426,6 +427,66 @@ def test_retry_noop_when_no_failed_sent_to(api_client, email_history):
426427
mock_client.send_message.assert_not_called()
427428

428429

430+
# ---- History Render SentTo As HTML -----------------------------------------
431+
432+
433+
@pytest.mark.django_db
434+
def test_render_sent_to_as_html_returns_html_with_rendered_context(api_client, email_history):
435+
sent_to = email_history.sent_to_list.get()
436+
response = api_client.get(
437+
reverse(
438+
"v1:admin-notification-email-history-render-sent-to-as-html",
439+
kwargs={"pk": email_history.id, "sent_to_id": sent_to.id},
440+
)
441+
)
442+
assert response.status_code == http.HTTPStatus.OK
443+
assert response["Content-Type"].startswith("text/html")
444+
body = response.content.decode()
445+
assert body.lstrip().startswith("<html")
446+
# email_history fixture의 context (name="길동")가 template_data를 거쳐 HTML에 반영되어야 함.
447+
assert "Hi 길동" in body
448+
assert "Hello 길동" in body
449+
450+
451+
@pytest.mark.django_db
452+
def test_render_sent_to_as_html_uses_history_template_data_snapshot(api_client, email_history, email_template):
453+
# History.template_data는 발송 시점의 snapshot이므로, 이후 Template.data를 바꿔도
454+
# 기존 sent_to의 렌더 결과는 영향을 받지 않아야 한다.
455+
sent_to = email_history.sent_to_list.get()
456+
url = reverse(
457+
"v1:admin-notification-email-history-render-sent-to-as-html",
458+
kwargs={"pk": email_history.id, "sent_to_id": sent_to.id},
459+
)
460+
461+
before = api_client.get(url).content.decode()
462+
463+
email_template.data = '{"title":"DIFFERENT {{ name }}","from_":"X","send_to":"Y","body":"CHANGED {{ name }}"}'
464+
email_template.save()
465+
466+
after = api_client.get(url).content.decode()
467+
assert before == after
468+
assert "Hi 길동" in after
469+
assert "Hello 길동" in after
470+
assert "DIFFERENT" not in after
471+
assert "CHANGED" not in after
472+
473+
474+
@pytest.mark.django_db
475+
def test_render_sent_to_as_html_404_when_sent_to_belongs_to_other_history(api_client, email_history, email_template):
476+
other = EmailNotificationHistory.objects.create_for_recipients(
477+
template=email_template,
478+
recipients=[{"recipient": "other@example.com", "context": {"name": "다른", "recipient": "y"}}],
479+
)
480+
other_sent_to = other.sent_to_list.get()
481+
response = api_client.get(
482+
reverse(
483+
"v1:admin-notification-email-history-render-sent-to-as-html",
484+
kwargs={"pk": email_history.id, "sent_to_id": other_sent_to.id},
485+
)
486+
)
487+
assert response.status_code == http.HTTPStatus.NOT_FOUND
488+
489+
429490
# ---- 채널 간 격리 -----------------------------------------------------------
430491

431492

app/admin_api/views/notification.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
NHNCloudSMSNotificationTemplate,
2525
)
2626
from rest_framework.decorators import action
27+
from rest_framework.generics import get_object_or_404
2728
from rest_framework.mixins import CreateModelMixin, ListModelMixin, RetrieveModelMixin
2829
from rest_framework.renderers import StaticHTMLRenderer
2930
from rest_framework.request import Request
@@ -32,7 +33,7 @@
3233

3334
TEMPLATE_READ_METHODS = ["list", "retrieve", "render_preview"]
3435
TEMPLATE_CRUD_METHODS = TEMPLATE_READ_METHODS + ["create", "update", "partial_update", "destroy"]
35-
HISTORY_METHODS = ["list", "retrieve", "create", "retry"]
36+
HISTORY_METHODS = ["list", "retrieve", "create", "retry", "render_sent_to_as_html"]
3637

3738

3839
# ---- Template -----------------------------------------------------------
@@ -95,6 +96,16 @@ def retry(self, *args: tuple, **kwargs: dict) -> Response:
9596
serializer.retry()
9697
return Response(data=serializer.data)
9798

99+
@extend_schema(responses=build_html_responses(names=["Notification History SentTo Render As HTML"]))
100+
@action(
101+
detail=True,
102+
methods=["get"],
103+
url_path=r"sent-to/(?P<sent_to_id>[^/.]+)/render",
104+
renderer_classes=[StaticHTMLRenderer],
105+
)
106+
def render_sent_to_as_html(self, request: Request, sent_to_id: str, *args: tuple, **kwargs: dict) -> Response:
107+
return Response(data=get_object_or_404(self.get_object().sent_to_list.all(), pk=sent_to_id).render_as_html())
108+
98109

99110
@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_NOTI_EMAIL]) for m in HISTORY_METHODS})
100111
class EmailNotificationHistoryAdminViewSet(_NotiHistoryAdminViewSetBase):
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from django.db import migrations, models
2+
3+
4+
class Migration(migrations.Migration):
5+
dependencies = [("notification", "0001_initial")]
6+
operations = [
7+
migrations.AddField(
8+
model_name="emailnotificationhistorysentto",
9+
name="failure_reason",
10+
field=models.TextField(blank=True, null=True),
11+
),
12+
migrations.AddField(
13+
model_name="nhncloudkakaoalimtalknotificationhistorysentto",
14+
name="failure_reason",
15+
field=models.TextField(blank=True, null=True),
16+
),
17+
migrations.AddField(
18+
model_name="nhncloudsmsnotificationhistorysentto",
19+
name="failure_reason",
20+
field=models.TextField(blank=True, null=True),
21+
),
22+
]

app/notification/models/base.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from enum import StrEnum, auto
22
from json import loads as json_loads
33
from logging import getLogger
4+
from traceback import format_exc
45
from typing import TYPE_CHECKING, Any, ClassVar, Generic, NotRequired, TypedDict, TypeVar
56
from uuid import uuid4
67

@@ -196,6 +197,7 @@ class NotificationHistorySentToBase(BaseAbstractModel):
196197
default=NotificationStatus.CREATED,
197198
db_index=True,
198199
)
200+
failure_reason = models.TextField(null=True, blank=True)
199201

200202
class Meta:
201203
abstract = True
@@ -276,13 +278,15 @@ def build_send_parameters(self) -> SendParameters:
276278

277279
def send(self) -> None:
278280
self.status = NotificationStatus.SENDING
279-
self.save(update_fields=["status"])
281+
self.failure_reason = None
282+
self.save(update_fields=["status", "failure_reason"])
280283

281284
try:
282285
self.history.client.send_message(data=self.build_send_parameters())
283286
except Exception:
284287
self.status = NotificationStatus.FAILED
285-
self.save(update_fields=["status"])
288+
self.failure_reason = format_exc()
289+
self.save(update_fields=["status", "failure_reason"])
286290
slack_logger.exception(
287291
"Notification send failed: history_id=%s template_code=%s recipient=%s",
288292
self.history.id,

app/notification/tasks.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from traceback import format_exc
2+
13
from celery import shared_task
24
from django.apps import apps
35
from notification.models.base import NotificationStatus, slack_logger
@@ -15,6 +17,10 @@ def send_notification_to_recipient(model_label: str, sent_to_id: str) -> None:
1517
except Exception:
1618
sent_to.refresh_from_db(fields=["status"])
1719
if sent_to.status != NotificationStatus.FAILED:
20+
sent_to_class.objects.filter(pk=sent_to_id).update(
21+
status=NotificationStatus.FAILED,
22+
failure_reason=format_exc(),
23+
)
1824
slack_logger.exception(
1925
"Batch send unexpected error: history_id=%s recipient=%s",
2026
sent_to.history_id,

app/notification/test/history_send_test.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,36 @@ def test_sent_to_failure_transitions_to_failed_and_propagates(email_template):
6161
assert sent_to.status == NotificationStatus.FAILED
6262

6363

64+
@pytest.mark.django_db
65+
def test_sent_to_failure_records_traceback_in_failure_reason(email_template):
66+
history = _create_history(email_template)
67+
sent_to = history.sent_to_list.get()
68+
with patch.object(EmailNotificationHistory, "client") as mock_client:
69+
mock_client.send_message.side_effect = RuntimeError("kaboom-xyz")
70+
with pytest.raises(RuntimeError):
71+
sent_to.send()
72+
sent_to.refresh_from_db()
73+
assert sent_to.failure_reason is not None
74+
assert "RuntimeError" in sent_to.failure_reason
75+
assert "kaboom-xyz" in sent_to.failure_reason
76+
assert "Traceback" in sent_to.failure_reason
77+
78+
79+
@pytest.mark.django_db
80+
def test_sent_to_retry_clears_previous_failure_reason(email_template):
81+
history = _create_history(email_template)
82+
sent_to = history.sent_to_list.get()
83+
sent_to.status = NotificationStatus.FAILED
84+
sent_to.failure_reason = "previous failure"
85+
sent_to.save(update_fields=["status", "failure_reason"])
86+
87+
with patch.object(EmailNotificationHistory, "client"):
88+
sent_to.send()
89+
sent_to.refresh_from_db()
90+
assert sent_to.status == NotificationStatus.SENT
91+
assert sent_to.failure_reason is None
92+
93+
6494
@pytest.mark.django_db
6595
def test_sent_to_failure_logs_to_slack_logger(email_template, caplog):
6696
history = _create_history(email_template, recipient="bad@example.com")

app/notification/test/tasks_test.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,28 @@ def test_task_logs_unexpected_error_when_inner_save_fails(sent_to, caplog):
9696
records = [r for r in caplog.records if "Batch send unexpected" in r.getMessage()]
9797
assert len(records) == 1
9898
assert records[0].exc_info is not None
99+
100+
101+
@pytest.mark.django_db
102+
def test_task_records_failure_reason_on_external_failure(sent_to):
103+
with patch.object(EmailNotificationHistory, "client") as mock_client:
104+
mock_client.send_message.side_effect = RuntimeError("boom-task")
105+
with pytest.raises(RuntimeError):
106+
send_notification_to_recipient(LABEL, sent_to.id)
107+
sent_to.refresh_from_db()
108+
assert sent_to.status == NotificationStatus.FAILED
109+
assert sent_to.failure_reason is not None
110+
assert "boom-task" in sent_to.failure_reason
111+
112+
113+
@pytest.mark.django_db
114+
def test_task_records_failure_reason_when_inner_save_fails(sent_to):
115+
# send() 내부 save() 자체가 망가졌을 때, task의 outer fallback이 queryset.update()로
116+
# status=FAILED와 failure_reason을 기록해야 한다.
117+
with patch.object(EmailNotificationHistorySentTo, "save", side_effect=RuntimeError("db down")):
118+
with pytest.raises(RuntimeError, match="db down"):
119+
send_notification_to_recipient(LABEL, sent_to.id)
120+
sent_to.refresh_from_db()
121+
assert sent_to.status == NotificationStatus.FAILED
122+
assert sent_to.failure_reason is not None
123+
assert "db down" in sent_to.failure_reason

0 commit comments

Comments
 (0)