Skip to content

Commit 024005f

Browse files
authored
Merge pull request #40 from pythonkr/feature/add-notification-app
feat: 카카오 알림톡 / SMS / 이메일 전송 기능 추가
2 parents 7aeb96c + b1f786d commit 024005f

37 files changed

Lines changed: 3762 additions & 9 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from django_filters import rest_framework as filters
2+
3+
4+
class NotificationTemplateAdminFilterSet(filters.FilterSet):
5+
code = filters.CharFilter(field_name="code", lookup_expr="icontains")
6+
title = filters.CharFilter(field_name="title", lookup_expr="icontains")
7+
8+
9+
class NotificationHistoryAdminFilterSet(filters.FilterSet):
10+
template = filters.UUIDFilter(field_name="template_id")
11+
created_by__username = filters.CharFilter(field_name="created_by__username", lookup_expr="icontains")
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
from typing import Any
2+
3+
from core.const.serializer import COMMON_ADMIN_FIELDS
4+
from core.serializer.base_abstract_serializer import BaseAbstractSerializer
5+
from core.serializer.json_schema_serializer import JsonSchemaSerializer
6+
from notification.models import (
7+
EmailNotificationHistory,
8+
EmailNotificationHistorySentTo,
9+
EmailNotificationTemplate,
10+
NHNCloudKakaoAlimTalkNotificationHistory,
11+
NHNCloudKakaoAlimTalkNotificationHistorySentTo,
12+
NHNCloudKakaoAlimTalkNotificationTemplate,
13+
NHNCloudSMSNotificationHistory,
14+
NHNCloudSMSNotificationHistorySentTo,
15+
NHNCloudSMSNotificationTemplate,
16+
)
17+
from notification.models.base import NotificationHistoryBase, NotificationTemplateBase, UnhandledVariableHandling
18+
from rest_framework import serializers
19+
20+
# ---- SentTo nested ----------------------------------------------------------
21+
22+
23+
class _NotiHistorySentToAdminSerializerBase(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer):
24+
class Meta:
25+
fields = COMMON_ADMIN_FIELDS + ("recipient", "context", "status")
26+
read_only_fields = (*COMMON_ADMIN_FIELDS, "status")
27+
28+
29+
class EmailNotificationHistorySentToAdminSerializer(_NotiHistorySentToAdminSerializerBase):
30+
class Meta(_NotiHistorySentToAdminSerializerBase.Meta):
31+
model = EmailNotificationHistorySentTo
32+
33+
34+
class NHNCloudSMSNotificationHistorySentToAdminSerializer(_NotiHistorySentToAdminSerializerBase):
35+
class Meta(_NotiHistorySentToAdminSerializerBase.Meta):
36+
model = NHNCloudSMSNotificationHistorySentTo
37+
38+
39+
class NHNCloudKakaoAlimTalkNotificationHistorySentToAdminSerializer(_NotiHistorySentToAdminSerializerBase):
40+
class Meta(_NotiHistorySentToAdminSerializerBase.Meta):
41+
model = NHNCloudKakaoAlimTalkNotificationHistorySentTo
42+
43+
44+
# ---- History --------------------------------------------------------------
45+
46+
47+
class _NotiHistoryAdminSerializerBase(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer):
48+
class SummarySerializer(serializers.Serializer):
49+
created = serializers.IntegerField(read_only=True)
50+
sending = serializers.IntegerField(read_only=True)
51+
sent = serializers.IntegerField(read_only=True)
52+
failed = serializers.IntegerField(read_only=True)
53+
54+
template_code = serializers.CharField(read_only=True)
55+
sent_to_status_summary = SummarySerializer(read_only=True)
56+
57+
class Meta:
58+
fields = COMMON_ADMIN_FIELDS + (
59+
"template",
60+
"template_code",
61+
"template_data",
62+
"sent_from",
63+
"sent_to_list",
64+
"sent_to_status_summary",
65+
)
66+
67+
def create(self, validated_data: dict[str, Any]) -> NotificationHistoryBase:
68+
# template이 명시되지 않은 templateless 경로면 transient (unsaved) template_class 인스턴스로 폴백.
69+
# Kakao는 template이 required + template_data/sent_from이 read-only라 or 우측이 실행되지 않음.
70+
template = validated_data.get("template") or self.Meta.model.template_class(
71+
data=validated_data.get("template_data") or "",
72+
sent_from=validated_data.get("sent_from") or "",
73+
)
74+
history = self.Meta.model.objects.create_for_recipients(
75+
template=template,
76+
recipients=validated_data["sent_to_list"],
77+
)
78+
history.send()
79+
history.refresh_from_db()
80+
return history
81+
82+
def retry(self) -> None:
83+
if not (self.instance and self.instance.pk):
84+
raise ValueError("인스턴스가 저장된 후에만 retry할 수 있습니다.")
85+
86+
self.instance.retry()
87+
self.instance.refresh_from_db()
88+
89+
90+
class EmailNotificationHistoryAdminSerializer(_NotiHistoryAdminSerializerBase):
91+
template = serializers.PrimaryKeyRelatedField(
92+
queryset=EmailNotificationTemplate.objects.filter_active(),
93+
required=False,
94+
allow_null=True,
95+
)
96+
# 모델은 base에서 max_length=256 CharField — Email 채널은 EmailField 검증 + RFC 길이 254 적용.
97+
sent_from = serializers.EmailField(max_length=254, required=False, default="")
98+
sent_to_list = EmailNotificationHistorySentToAdminSerializer(many=True, allow_empty=False)
99+
100+
class Meta(_NotiHistoryAdminSerializerBase.Meta):
101+
model = EmailNotificationHistory
102+
extra_kwargs = {"template_data": {"required": False, "default": ""}}
103+
104+
105+
class NHNCloudSMSNotificationHistoryAdminSerializer(_NotiHistoryAdminSerializerBase):
106+
template = serializers.PrimaryKeyRelatedField(
107+
queryset=NHNCloudSMSNotificationTemplate.objects.filter_active(),
108+
required=False,
109+
allow_null=True,
110+
)
111+
# SMS 발신번호는 최대 13자리.
112+
sent_from = serializers.CharField(max_length=13, required=False, default="")
113+
sent_to_list = NHNCloudSMSNotificationHistorySentToAdminSerializer(many=True, allow_empty=False)
114+
115+
class Meta(_NotiHistoryAdminSerializerBase.Meta):
116+
model = NHNCloudSMSNotificationHistory
117+
extra_kwargs = {"template_data": {"required": False, "default": ""}}
118+
119+
120+
class NHNCloudKakaoAlimTalkNotificationHistoryAdminSerializer(_NotiHistoryAdminSerializerBase):
121+
template = serializers.PrimaryKeyRelatedField(
122+
queryset=NHNCloudKakaoAlimTalkNotificationTemplate.objects.filter_active(),
123+
required=True, # Kakao 알림톡은 템플릿 필수
124+
)
125+
sent_to_list = NHNCloudKakaoAlimTalkNotificationHistorySentToAdminSerializer(many=True, allow_empty=False)
126+
127+
class Meta(_NotiHistoryAdminSerializerBase.Meta):
128+
model = NHNCloudKakaoAlimTalkNotificationHistory
129+
read_only_fields = ("template_data", "sent_from") # template에서 snapshot되므로 입력 불가
130+
131+
132+
# ---- Template ---------------------------------------------------------------
133+
134+
135+
class _NotiTemplateAdminSerializerBase(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer):
136+
template_variables = serializers.SerializerMethodField()
137+
138+
class Meta:
139+
fields = COMMON_ADMIN_FIELDS + ("code", "title", "description", "data", "sent_from", "template_variables")
140+
141+
def get_template_variables(self, obj: NotificationTemplateBase) -> list[str]:
142+
return sorted(obj.template_variables)
143+
144+
def render(self, context: dict[str, Any]) -> str:
145+
return self.instance.build_preview_sent_to(context).render_as_html(undef_var=UnhandledVariableHandling.RANDOM)
146+
147+
148+
class EmailNotificationTemplateAdminSerializer(_NotiTemplateAdminSerializerBase):
149+
sent_from = serializers.EmailField(max_length=254)
150+
151+
class Meta(_NotiTemplateAdminSerializerBase.Meta):
152+
model = EmailNotificationTemplate
153+
154+
155+
class NHNCloudKakaoAlimTalkNotificationTemplateAdminSerializer(_NotiTemplateAdminSerializerBase):
156+
class Meta(_NotiTemplateAdminSerializerBase.Meta):
157+
model = NHNCloudKakaoAlimTalkNotificationTemplate
158+
read_only_fields = (
159+
_NotiTemplateAdminSerializerBase.Meta.fields
160+
) # NHN Cloud Console에서 관리되므로 모든 필드 read-only.
161+
162+
163+
class NHNCloudSMSNotificationTemplateAdminSerializer(_NotiTemplateAdminSerializerBase):
164+
sent_from = serializers.CharField(max_length=13)
165+
166+
class Meta(_NotiTemplateAdminSerializerBase.Meta):
167+
model = NHNCloudSMSNotificationTemplate
168+
169+
170+
class NotificationTemplateRenderRequestAdminSerializer(serializers.Serializer):
171+
context = serializers.JSONField(required=False, default=dict)

app/admin_api/test/conftest.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import pytest
2+
from core.models import BaseAbstractModelQuerySet
3+
from core.util.thread_local import thread_local
4+
from notification.models import (
5+
EmailNotificationTemplate,
6+
NHNCloudKakaoAlimTalkNotificationTemplate,
7+
NHNCloudSMSNotificationTemplate,
8+
)
9+
from rest_framework.test import APIClient
10+
from user.models import UserExt
11+
12+
13+
@pytest.fixture(autouse=True)
14+
def _isolate_thread_local():
15+
# ThreadLocalMiddleware가 thread_local.current_request를 정리하지 않아, 직전 테스트의 (롤백된) user를
16+
# get_current_user()가 반환하면서 FK violation이 발생. 양쪽으로 정리.
17+
if hasattr(thread_local, "current_request"):
18+
del thread_local.current_request
19+
yield
20+
if hasattr(thread_local, "current_request"):
21+
del thread_local.current_request
22+
23+
24+
@pytest.fixture
25+
def superuser(db) -> UserExt:
26+
return UserExt.objects.create_superuser(username="admin", email="admin@example.com", password="x") # nosec B106
27+
28+
29+
@pytest.fixture
30+
def api_client(superuser) -> APIClient:
31+
client = APIClient()
32+
client.force_authenticate(user=superuser)
33+
return client
34+
35+
36+
@pytest.fixture
37+
def email_template(superuser) -> EmailNotificationTemplate:
38+
return EmailNotificationTemplate.objects.create(
39+
code="welcome",
40+
title="환영합니다",
41+
sent_from="from@example.com",
42+
data='{"title":"Hi {{ name }}","from_":"f","send_to":"{{ recipient }}","body":"Hello {{ name }}"}',
43+
created_by=superuser,
44+
updated_by=superuser,
45+
)
46+
47+
48+
@pytest.fixture
49+
def sms_template(superuser) -> NHNCloudSMSNotificationTemplate:
50+
return NHNCloudSMSNotificationTemplate.objects.create(
51+
code="sms-welcome",
52+
title="SMS 환영",
53+
sent_from="0212345678",
54+
data='{"body":"안녕 {{ name }}님"}',
55+
created_by=superuser,
56+
updated_by=superuser,
57+
)
58+
59+
60+
@pytest.fixture
61+
def kakao_template(superuser) -> NHNCloudKakaoAlimTalkNotificationTemplate:
62+
# NHN Cloud 측에서 동기화하는 모델이므로 일반 .create() 가 차단됨 — bulk_create로 우회.
63+
template = NHNCloudKakaoAlimTalkNotificationTemplate(
64+
code="kakao-welcome",
65+
title="알림톡 환영",
66+
sent_from="S1",
67+
data='{"templateContent":"안녕 #{name}","buttons":[]}',
68+
created_by=superuser,
69+
updated_by=superuser,
70+
)
71+
[created] = BaseAbstractModelQuerySet(model=NHNCloudKakaoAlimTalkNotificationTemplate).bulk_create([template])
72+
return created

0 commit comments

Comments
 (0)