diff --git a/app/admin_api/filtersets/notification.py b/app/admin_api/filtersets/notification.py new file mode 100644 index 0000000..d94c9df --- /dev/null +++ b/app/admin_api/filtersets/notification.py @@ -0,0 +1,11 @@ +from django_filters import rest_framework as filters + + +class NotificationTemplateAdminFilterSet(filters.FilterSet): + code = filters.CharFilter(field_name="code", lookup_expr="icontains") + title = filters.CharFilter(field_name="title", lookup_expr="icontains") + + +class NotificationHistoryAdminFilterSet(filters.FilterSet): + template = filters.UUIDFilter(field_name="template_id") + created_by__username = filters.CharFilter(field_name="created_by__username", lookup_expr="icontains") diff --git a/app/admin_api/serializers/notification.py b/app/admin_api/serializers/notification.py new file mode 100644 index 0000000..6996e58 --- /dev/null +++ b/app/admin_api/serializers/notification.py @@ -0,0 +1,171 @@ +from typing import Any + +from core.const.serializer import COMMON_ADMIN_FIELDS +from core.serializer.base_abstract_serializer import BaseAbstractSerializer +from core.serializer.json_schema_serializer import JsonSchemaSerializer +from notification.models import ( + EmailNotificationHistory, + EmailNotificationHistorySentTo, + EmailNotificationTemplate, + NHNCloudKakaoAlimTalkNotificationHistory, + NHNCloudKakaoAlimTalkNotificationHistorySentTo, + NHNCloudKakaoAlimTalkNotificationTemplate, + NHNCloudSMSNotificationHistory, + NHNCloudSMSNotificationHistorySentTo, + NHNCloudSMSNotificationTemplate, +) +from notification.models.base import NotificationHistoryBase, NotificationTemplateBase, UnhandledVariableHandling +from rest_framework import serializers + +# ---- SentTo nested ---------------------------------------------------------- + + +class _NotiHistorySentToAdminSerializerBase(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer): + class Meta: + fields = COMMON_ADMIN_FIELDS + ("recipient", "context", "status") + read_only_fields = (*COMMON_ADMIN_FIELDS, "status") + + +class EmailNotificationHistorySentToAdminSerializer(_NotiHistorySentToAdminSerializerBase): + class Meta(_NotiHistorySentToAdminSerializerBase.Meta): + model = EmailNotificationHistorySentTo + + +class NHNCloudSMSNotificationHistorySentToAdminSerializer(_NotiHistorySentToAdminSerializerBase): + class Meta(_NotiHistorySentToAdminSerializerBase.Meta): + model = NHNCloudSMSNotificationHistorySentTo + + +class NHNCloudKakaoAlimTalkNotificationHistorySentToAdminSerializer(_NotiHistorySentToAdminSerializerBase): + class Meta(_NotiHistorySentToAdminSerializerBase.Meta): + model = NHNCloudKakaoAlimTalkNotificationHistorySentTo + + +# ---- History -------------------------------------------------------------- + + +class _NotiHistoryAdminSerializerBase(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer): + class SummarySerializer(serializers.Serializer): + created = serializers.IntegerField(read_only=True) + sending = serializers.IntegerField(read_only=True) + sent = serializers.IntegerField(read_only=True) + failed = serializers.IntegerField(read_only=True) + + template_code = serializers.CharField(read_only=True) + sent_to_status_summary = SummarySerializer(read_only=True) + + class Meta: + fields = COMMON_ADMIN_FIELDS + ( + "template", + "template_code", + "template_data", + "sent_from", + "sent_to_list", + "sent_to_status_summary", + ) + + def create(self, validated_data: dict[str, Any]) -> NotificationHistoryBase: + # template이 명시되지 않은 templateless 경로면 transient (unsaved) template_class 인스턴스로 폴백. + # Kakao는 template이 required + template_data/sent_from이 read-only라 or 우측이 실행되지 않음. + template = validated_data.get("template") or self.Meta.model.template_class( + data=validated_data.get("template_data") or "", + sent_from=validated_data.get("sent_from") or "", + ) + history = self.Meta.model.objects.create_for_recipients( + template=template, + recipients=validated_data["sent_to_list"], + ) + history.send() + history.refresh_from_db() + return history + + def retry(self) -> None: + if not (self.instance and self.instance.pk): + raise ValueError("인스턴스가 저장된 후에만 retry할 수 있습니다.") + + self.instance.retry() + self.instance.refresh_from_db() + + +class EmailNotificationHistoryAdminSerializer(_NotiHistoryAdminSerializerBase): + template = serializers.PrimaryKeyRelatedField( + queryset=EmailNotificationTemplate.objects.filter_active(), + required=False, + allow_null=True, + ) + # 모델은 base에서 max_length=256 CharField — Email 채널은 EmailField 검증 + RFC 길이 254 적용. + sent_from = serializers.EmailField(max_length=254, required=False, default="") + sent_to_list = EmailNotificationHistorySentToAdminSerializer(many=True, allow_empty=False) + + class Meta(_NotiHistoryAdminSerializerBase.Meta): + model = EmailNotificationHistory + extra_kwargs = {"template_data": {"required": False, "default": ""}} + + +class NHNCloudSMSNotificationHistoryAdminSerializer(_NotiHistoryAdminSerializerBase): + template = serializers.PrimaryKeyRelatedField( + queryset=NHNCloudSMSNotificationTemplate.objects.filter_active(), + required=False, + allow_null=True, + ) + # SMS 발신번호는 최대 13자리. + sent_from = serializers.CharField(max_length=13, required=False, default="") + sent_to_list = NHNCloudSMSNotificationHistorySentToAdminSerializer(many=True, allow_empty=False) + + class Meta(_NotiHistoryAdminSerializerBase.Meta): + model = NHNCloudSMSNotificationHistory + extra_kwargs = {"template_data": {"required": False, "default": ""}} + + +class NHNCloudKakaoAlimTalkNotificationHistoryAdminSerializer(_NotiHistoryAdminSerializerBase): + template = serializers.PrimaryKeyRelatedField( + queryset=NHNCloudKakaoAlimTalkNotificationTemplate.objects.filter_active(), + required=True, # Kakao 알림톡은 템플릿 필수 + ) + sent_to_list = NHNCloudKakaoAlimTalkNotificationHistorySentToAdminSerializer(many=True, allow_empty=False) + + class Meta(_NotiHistoryAdminSerializerBase.Meta): + model = NHNCloudKakaoAlimTalkNotificationHistory + read_only_fields = ("template_data", "sent_from") # template에서 snapshot되므로 입력 불가 + + +# ---- Template --------------------------------------------------------------- + + +class _NotiTemplateAdminSerializerBase(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer): + template_variables = serializers.SerializerMethodField() + + class Meta: + fields = COMMON_ADMIN_FIELDS + ("code", "title", "description", "data", "sent_from", "template_variables") + + def get_template_variables(self, obj: NotificationTemplateBase) -> list[str]: + return sorted(obj.template_variables) + + def render(self, context: dict[str, Any]) -> str: + return self.instance.build_preview_sent_to(context).render_as_html(undef_var=UnhandledVariableHandling.RANDOM) + + +class EmailNotificationTemplateAdminSerializer(_NotiTemplateAdminSerializerBase): + sent_from = serializers.EmailField(max_length=254) + + class Meta(_NotiTemplateAdminSerializerBase.Meta): + model = EmailNotificationTemplate + + +class NHNCloudKakaoAlimTalkNotificationTemplateAdminSerializer(_NotiTemplateAdminSerializerBase): + class Meta(_NotiTemplateAdminSerializerBase.Meta): + model = NHNCloudKakaoAlimTalkNotificationTemplate + read_only_fields = ( + _NotiTemplateAdminSerializerBase.Meta.fields + ) # NHN Cloud Console에서 관리되므로 모든 필드 read-only. + + +class NHNCloudSMSNotificationTemplateAdminSerializer(_NotiTemplateAdminSerializerBase): + sent_from = serializers.CharField(max_length=13) + + class Meta(_NotiTemplateAdminSerializerBase.Meta): + model = NHNCloudSMSNotificationTemplate + + +class NotificationTemplateRenderRequestAdminSerializer(serializers.Serializer): + context = serializers.JSONField(required=False, default=dict) diff --git a/app/admin_api/test/conftest.py b/app/admin_api/test/conftest.py new file mode 100644 index 0000000..59f1211 --- /dev/null +++ b/app/admin_api/test/conftest.py @@ -0,0 +1,72 @@ +import pytest +from core.models import BaseAbstractModelQuerySet +from core.util.thread_local import thread_local +from notification.models import ( + EmailNotificationTemplate, + NHNCloudKakaoAlimTalkNotificationTemplate, + NHNCloudSMSNotificationTemplate, +) +from rest_framework.test import APIClient +from user.models import UserExt + + +@pytest.fixture(autouse=True) +def _isolate_thread_local(): + # ThreadLocalMiddleware가 thread_local.current_request를 정리하지 않아, 직전 테스트의 (롤백된) user를 + # get_current_user()가 반환하면서 FK violation이 발생. 양쪽으로 정리. + if hasattr(thread_local, "current_request"): + del thread_local.current_request + yield + if hasattr(thread_local, "current_request"): + del thread_local.current_request + + +@pytest.fixture +def superuser(db) -> UserExt: + return UserExt.objects.create_superuser(username="admin", email="admin@example.com", password="x") # nosec B106 + + +@pytest.fixture +def api_client(superuser) -> APIClient: + client = APIClient() + client.force_authenticate(user=superuser) + return client + + +@pytest.fixture +def email_template(superuser) -> EmailNotificationTemplate: + return EmailNotificationTemplate.objects.create( + code="welcome", + title="환영합니다", + sent_from="from@example.com", + data='{"title":"Hi {{ name }}","from_":"f","send_to":"{{ recipient }}","body":"Hello {{ name }}"}', + created_by=superuser, + updated_by=superuser, + ) + + +@pytest.fixture +def sms_template(superuser) -> NHNCloudSMSNotificationTemplate: + return NHNCloudSMSNotificationTemplate.objects.create( + code="sms-welcome", + title="SMS 환영", + sent_from="0212345678", + data='{"body":"안녕 {{ name }}님"}', + created_by=superuser, + updated_by=superuser, + ) + + +@pytest.fixture +def kakao_template(superuser) -> NHNCloudKakaoAlimTalkNotificationTemplate: + # NHN Cloud 측에서 동기화하는 모델이므로 일반 .create() 가 차단됨 — bulk_create로 우회. + template = NHNCloudKakaoAlimTalkNotificationTemplate( + code="kakao-welcome", + title="알림톡 환영", + sent_from="S1", + data='{"templateContent":"안녕 #{name}","buttons":[]}', + created_by=superuser, + updated_by=superuser, + ) + [created] = BaseAbstractModelQuerySet(model=NHNCloudKakaoAlimTalkNotificationTemplate).bulk_create([template]) + return created diff --git a/app/admin_api/test/notification_test.py b/app/admin_api/test/notification_test.py new file mode 100644 index 0000000..ac9b99e --- /dev/null +++ b/app/admin_api/test/notification_test.py @@ -0,0 +1,394 @@ +import http +from unittest.mock import patch + +import pytest +from django.urls import reverse +from notification.models import ( + EmailNotificationHistory, + EmailNotificationTemplate, + NHNCloudSMSNotificationHistory, +) +from notification.models.base import NotificationStatus +from rest_framework.test import APIClient + +# ---- Auth ------------------------------------------------------------------- + + +@pytest.mark.django_db +def test_unauthenticated_request_is_rejected(email_template): + response = APIClient().get(reverse("v1:admin-notification-email-template-list")) + assert response.status_code in (http.HTTPStatus.FORBIDDEN, http.HTTPStatus.UNAUTHORIZED) + + +# ---- Template CRUD (Email) -------------------------------------------------- + + +@pytest.mark.django_db +def test_template_list_returns_active_templates(api_client, email_template): + response = api_client.get(reverse("v1:admin-notification-email-template-list")) + assert response.status_code == http.HTTPStatus.OK + body = response.json() + assert any(row["code"] == email_template.code for row in body) + + +@pytest.mark.django_db +def test_template_retrieve_includes_template_variables(api_client, email_template): + response = api_client.get(reverse("v1:admin-notification-email-template-detail", kwargs={"pk": email_template.id})) + assert response.status_code == http.HTTPStatus.OK + body = response.json() + assert sorted(body["template_variables"]) == ["name", "recipient"] + + +@pytest.mark.django_db +def test_template_create(api_client): + response = api_client.post( + reverse("v1:admin-notification-email-template-list"), + data={ + "code": "new-tpl", + "title": "신규", + "sent_from": "from@example.com", + "data": '{"title":"x","from_":"f","send_to":"r","body":"b"}', + }, + format="json", + ) + assert response.status_code == http.HTTPStatus.CREATED + assert EmailNotificationTemplate.objects.filter(code="new-tpl").exists() + + +@pytest.mark.django_db +def test_template_partial_update(api_client, email_template): + response = api_client.patch( + reverse("v1:admin-notification-email-template-detail", kwargs={"pk": email_template.id}), + data={"title": "변경된 제목"}, + format="json", + ) + assert response.status_code == http.HTTPStatus.OK + email_template.refresh_from_db() + assert email_template.title == "변경된 제목" + + +@pytest.mark.django_db +def test_template_destroy_soft_deletes(api_client, email_template): + response = api_client.delete( + reverse("v1:admin-notification-email-template-detail", kwargs={"pk": email_template.id}) + ) + assert response.status_code == http.HTTPStatus.NO_CONTENT + email_template.refresh_from_db() + assert email_template.deleted_at is not None + + +# ---- Template Filters -------------------------------------------------------- + + +@pytest.mark.django_db +def test_template_list_filter_by_code(api_client, superuser): + EmailNotificationTemplate.objects.create( + code="welcome", title="A", sent_from="a@x.com", data="{}", created_by=superuser, updated_by=superuser + ) + EmailNotificationTemplate.objects.create( + code="goodbye", title="B", sent_from="a@x.com", data="{}", created_by=superuser, updated_by=superuser + ) + + response = api_client.get(reverse("v1:admin-notification-email-template-list"), {"code": "welc"}) + assert response.status_code == http.HTTPStatus.OK + codes = [row["code"] for row in response.json()] + assert codes == ["welcome"] + + +@pytest.mark.django_db +def test_template_list_filter_by_title(api_client, superuser): + EmailNotificationTemplate.objects.create( + code="t1", title="환영합니다", sent_from="a@x.com", data="{}", created_by=superuser, updated_by=superuser + ) + EmailNotificationTemplate.objects.create( + code="t2", title="안녕히가세요", sent_from="a@x.com", data="{}", created_by=superuser, updated_by=superuser + ) + + response = api_client.get(reverse("v1:admin-notification-email-template-list"), {"title": "환영"}) + assert response.status_code == http.HTTPStatus.OK + titles = [row["title"] for row in response.json()] + assert titles == ["환영합니다"] + + +# ---- Render Preview --------------------------------------------------------- + + +@pytest.mark.django_db +def test_render_preview_returns_html_with_text_html_content_type(api_client, email_template): + response = api_client.post( + reverse("v1:admin-notification-email-template-render-preview", kwargs={"pk": email_template.id}), + data={"context": {"name": "길동", "recipient": "to@example.com"}}, + format="json", + ) + assert response.status_code == http.HTTPStatus.OK + assert response["Content-Type"].startswith("text/html") + body = response.content.decode() + assert body.lstrip().startswith(" Response: + request_serializer = NotificationTemplateRenderRequestAdminSerializer(data=request.data) + request_serializer.is_valid(raise_exception=True) + + template_serializer = self.get_serializer(instance=self.get_object()) + return Response(data=template_serializer.render(request_serializer.validated_data["context"])) + + +@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_NOTI_EMAIL]) for m in TEMPLATE_CRUD_METHODS}) +class EmailNotificationTemplateAdminViewSet(_NotiTemplateAdminActionMixin, ModelViewSet): + http_method_names = ["get", "post", "patch", "delete"] + serializer_class = EmailNotificationTemplateAdminSerializer + queryset = EmailNotificationTemplate.objects.filter_active().select_related_with_user() + + +@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_NOTI_KAKAO_ALIMTALK]) for m in TEMPLATE_READ_METHODS}) +class NHNCloudKakaoAlimTalkNotificationTemplateAdminViewSet(_NotiTemplateAdminActionMixin, ReadOnlyModelViewSet): + serializer_class = NHNCloudKakaoAlimTalkNotificationTemplateAdminSerializer + queryset = NHNCloudKakaoAlimTalkNotificationTemplate.objects.filter_active().select_related_with_user() + + +@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_NOTI_SMS]) for m in TEMPLATE_CRUD_METHODS}) +class NHNCloudSMSNotificationTemplateAdminViewSet(_NotiTemplateAdminActionMixin, ModelViewSet): + http_method_names = ["get", "post", "patch", "delete"] + serializer_class = NHNCloudSMSNotificationTemplateAdminSerializer + queryset = NHNCloudSMSNotificationTemplate.objects.filter_active().select_related_with_user() + + +# ---- History ---------------------------------------------------------------- + + +class _NotiHistoryAdminViewSetBase(CreateModelMixin, ListModelMixin, RetrieveModelMixin, JsonSchemaViewSet): + permission_classes = [IsSuperUser] + filterset_class = NotificationHistoryAdminFilterSet + + @action(detail=True, methods=["post"], url_path="retry") + def retry(self, *args: tuple, **kwargs: dict) -> Response: + serializer = self.get_serializer(instance=self.get_object()) + serializer.retry() + return Response(data=serializer.data) + + +@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_NOTI_EMAIL]) for m in HISTORY_METHODS}) +class EmailNotificationHistoryAdminViewSet(_NotiHistoryAdminViewSetBase): + serializer_class = EmailNotificationHistoryAdminSerializer + queryset = ( + EmailNotificationHistory.objects.filter_active() + .select_related_with_user("template") + .prefetch_related("sent_to_list") + ) + + +@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_NOTI_KAKAO_ALIMTALK]) for m in HISTORY_METHODS}) +class NHNCloudKakaoAlimTalkNotificationHistoryAdminViewSet(_NotiHistoryAdminViewSetBase): + serializer_class = NHNCloudKakaoAlimTalkNotificationHistoryAdminSerializer + queryset = ( + NHNCloudKakaoAlimTalkNotificationHistory.objects.filter_active() + .select_related_with_user("template") + .prefetch_related("sent_to_list") + ) + + +@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_NOTI_SMS]) for m in HISTORY_METHODS}) +class NHNCloudSMSNotificationHistoryAdminViewSet(_NotiHistoryAdminViewSetBase): + serializer_class = NHNCloudSMSNotificationHistoryAdminSerializer + queryset = ( + NHNCloudSMSNotificationHistory.objects.filter_active() + .select_related_with_user("template") + .prefetch_related("sent_to_list") + ) diff --git a/app/core/const/tag.py b/app/core/const/tag.py index 6530d54..de4f53a 100644 --- a/app/core/const/tag.py +++ b/app/core/const/tag.py @@ -12,6 +12,9 @@ class OpenAPITag: ADMIN_EVENT_SPONSOR = "Admin > Event > Sponsor" ADMIN_JSON_SCHEMA = "Admin > JSON Schema" ADMIN_MODIFICATION_AUDIT = "Admin > Modification Audit" + ADMIN_NOTI_EMAIL = "Admin > Notification > Email" + ADMIN_NOTI_KAKAO_ALIMTALK = "Admin > Notification > Kakao Alimtalk" + ADMIN_NOTI_SMS = "Admin > Notification > SMS" PARTICIPANT_PORTAL_USER = "Participant Portal > Sign-In & Sign-Out & My Profile" PARTICIPANT_PORTAL_PUBLIC_FILE = "Participant Portal > Public File" diff --git a/app/core/email_backends.py b/app/core/email_backends.py new file mode 100644 index 0000000..19eccb3 --- /dev/null +++ b/app/core/email_backends.py @@ -0,0 +1,86 @@ +from base64 import b64encode +from datetime import UTC, datetime, timedelta +from logging import getLogger +from smtplib import SMTPAuthenticationError +from typing import cast + +from core.const.google_api import GOOGLE_OAUTH2_TOKEN_URI +from django.conf import settings +from django.core.mail.backends.smtp import EmailBackend +from external_api.google_oauth2.models import GoogleOAuth2 +from httpx import post as httpx_post + +logger = getLogger(__name__) + +# Google OAuth2 access token은 보통 1시간 유효. 만료 60초 전부터 만료된 것으로 간주해 재발급(clock skew + race buffer). +_ACCESS_TOKEN_DEFAULT_TTL = timedelta(hours=1) +_ACCESS_TOKEN_TTL_BUFFER = timedelta(seconds=60) +# refresh_token → (access_token, expires_at: aware UTC) +_access_token_cache: dict[str, tuple[str, datetime]] = {} + + +class GmailOAuth2Backend(EmailBackend): + def open(self) -> bool: + if self.connection: + return False + + # 부모 EmailBackend.open()에 connect/STARTTLS/EHLO를 위임하고, 여기는 XOAUTH2만 추가. password를 비워 부모의 LOGIN 단계는 건너뛰게 함. + saved_password = self.password + self.password = "" # nosec: B105 — 부모의 LOGIN 단계 건너뛰기 위해 일시적으로 비움. 실제 인증은 XOAUTH2. + try: + opened = super().open() + if not opened: + return opened + self._authenticate_xoauth2() + return True + except OSError: + if not self.fail_silently: + raise + return False + finally: + self.password = saved_password + + @property + def _access_token(self) -> str: + if not ( + record := cast(GoogleOAuth2 | None, GoogleOAuth2.objects.filter_active().order_by("-created_at").first()) + ): + raise RuntimeError( + "No GoogleOAuth2 refresh token configured. Run /v1/external-api/google-oauth2/authorize first.", + ) + refresh_token = cast(str, record.refresh_token) + + access_token, expires_at = _access_token_cache.get(refresh_token, (None, None)) + if access_token and expires_at and expires_at > datetime.now(UTC): + return access_token + + payload = ( + httpx_post( + url=GOOGLE_OAUTH2_TOKEN_URI, + data={ + "client_id": settings.GOOGLE_CLOUD.CLIENT_ID, + "client_secret": settings.GOOGLE_CLOUD.CLIENT_SECRET, + "refresh_token": refresh_token, + "grant_type": "refresh_token", + }, + timeout=10.0, + ) + .raise_for_status() + .json() + ) + + access_token = payload["access_token"] + expires_in = timedelta(seconds=payload.get("expires_in", _ACCESS_TOKEN_DEFAULT_TTL.total_seconds())) + _access_token_cache[refresh_token] = (access_token, datetime.now(UTC) + expires_in - _ACCESS_TOKEN_TTL_BUFFER) + + return access_token + + def _authenticate_xoauth2(self) -> None: + if not self.username: + raise SMTPAuthenticationError(530, b"EMAIL_HOST_USER must be set to the Gmail address.") + + auth_payload = f"user={self.username}\x01auth=Bearer {self._access_token}\x01\x01" + auth_payload = f"XOAUTH2 {b64encode(auth_payload.encode()).decode()}" + code, response = self.connection.docmd("AUTH", auth_payload) + if code != 235: + raise SMTPAuthenticationError(code, response) diff --git a/app/core/external_apis/__interface__.py b/app/core/external_apis/__interface__.py new file mode 100644 index 0000000..230608a --- /dev/null +++ b/app/core/external_apis/__interface__.py @@ -0,0 +1,14 @@ +from abc import ABC, abstractmethod +from typing import Any, TypedDict + + +class SendParameters(TypedDict): + payload: dict[str, Any] + send_to: str + sent_from: str | None + template_code: str + + +class NotificationServiceInterface(ABC): + @abstractmethod + def send_message(self, *, data: SendParameters) -> None: ... diff --git a/app/core/external_apis/nhn_cloud_kakao_alimtalk.py b/app/core/external_apis/nhn_cloud_kakao_alimtalk.py new file mode 100644 index 0000000..857c2a9 --- /dev/null +++ b/app/core/external_apis/nhn_cloud_kakao_alimtalk.py @@ -0,0 +1,51 @@ +# https://docs.nhncloud.com/ko/Notification/KakaoTalk%20Bizmessage/ko/alimtalk-api-guide/ +from logging import getLogger +from typing import Any + +from core.external_apis.__interface__ import NotificationServiceInterface, SendParameters +from django.conf import settings +from httpx import Client + +logger = getLogger(__name__) + + +class NHNCloudKakaoAlimTalkClient(NotificationServiceInterface): + session: Client + + def __init__(self) -> None: + self.session = Client( + base_url=f"{settings.NHN_CLOUD.kakao_alimtalk.base_url}/alimtalk/v2.3/appkeys/{settings.NHN_CLOUD.app_key}", + headers={ + "Content-Type": "application/json", + "X-Secret-Key": settings.NHN_CLOUD.secret_key, + }, + timeout=settings.NHN_CLOUD.kakao_alimtalk.timeout, + ) + + def send_message(self, *, data: SendParameters) -> None: + if not data["sent_from"]: + raise ValueError("sent_from is required to send NHN Cloud Kakao Alimtalk message.") + + body = { + "senderKey": data["sent_from"], + "templateCode": data["template_code"], + "recipientList": [{"recipientNo": data["send_to"], "templateParameter": data["payload"]}], + } + result = self.session.post("/messages", json=body).raise_for_status().json() + logger.info( + "Alimtalk send results: result_code=%s, result_message=%s", + result["header"]["resultCode"], + result["header"]["resultMessage"], + ) + + def get_sender_list(self) -> dict[str, Any]: + return self.session.get("/senders").raise_for_status().json() + + def list_template_categories(self) -> dict[str, Any]: + return self.session.get("/template/categories").raise_for_status().json() + + def list_templates(self, sender_key: str, **params: Any) -> dict[str, Any]: + return self.session.get(f"/senders/{sender_key}/templates", params=params or None).raise_for_status().json() + + +nhn_cloud_kakao_alimtalk_client = NHNCloudKakaoAlimTalkClient() diff --git a/app/core/external_apis/nhn_cloud_sms.py b/app/core/external_apis/nhn_cloud_sms.py new file mode 100644 index 0000000..93ecbd9 --- /dev/null +++ b/app/core/external_apis/nhn_cloud_sms.py @@ -0,0 +1,60 @@ +# https://docs.nhncloud.com/ko/Notification/SMS/ko/api-guide/ +from logging import getLogger +from typing import Any, TypedDict, cast + +from core.external_apis.__interface__ import NotificationServiceInterface, SendParameters +from django.conf import settings +from httpx import Client + +logger = getLogger(__name__) + + +class SMSPayload(TypedDict, total=False): + title: str + body: str + + +class NHNCloudSMSClient(NotificationServiceInterface): + session: Client + + def __init__(self) -> None: + self.session = Client( + base_url=f"{settings.NHN_CLOUD.sms.base_url}/sms/v3.0/appKeys/{settings.NHN_CLOUD.app_key}", + headers={ + "Content-Type": "application/json;charset=UTF-8", + "X-Secret-Key": settings.NHN_CLOUD.secret_key, + }, + timeout=settings.NHN_CLOUD.sms.timeout, + ) + + def send_message(self, *, data: SendParameters) -> None: + if not data["sent_from"]: + raise ValueError("sent_from is required to send NHN Cloud SMS message.") + + payload = cast(SMSPayload, data["payload"]) + if not payload.get("body"): + raise ValueError("body is required in payload.") + + body: dict[str, Any] = { + "sendNo": data["sent_from"], + "body": payload["body"], + "recipientList": [{"recipientNo": data["send_to"]}], + } + if data["template_code"]: + body["templateId"] = data["template_code"] + + if title := payload.get("title"): + body["title"] = title + url = "/sender/mms" + else: + url = "/sender/sms" + + result = self.session.post(url, json=body).raise_for_status().json() + logger.info( + "SMS send results: result_code=%s, result_message=%s", + result["header"]["resultCode"], + result["header"]["resultMessage"], + ) + + +nhn_cloud_sms_client = NHNCloudSMSClient() diff --git a/app/core/external_apis/smtp_email.py b/app/core/external_apis/smtp_email.py new file mode 100644 index 0000000..088ea75 --- /dev/null +++ b/app/core/external_apis/smtp_email.py @@ -0,0 +1,35 @@ +from logging import getLogger +from typing import TypedDict, cast + +from core.external_apis.__interface__ import NotificationServiceInterface, SendParameters +from django.core.mail import EmailMessage + +logger = getLogger(__name__) + + +class EmailPayload(TypedDict): + title: str + body: str + + +class EmailClient(NotificationServiceInterface): + def send_message(self, *, data: SendParameters) -> None: + if not data["sent_from"]: + raise ValueError("sent_from is required to send Email.") + + payload = cast(EmailPayload, data["payload"]) + if not payload.get("title"): + raise ValueError("title is required in payload.") + + message = EmailMessage( + subject=payload["title"], + body=payload.get("body", ""), + from_email=data["sent_from"], + to=[data["send_to"]], + ) + message.content_subtype = "html" + sent_count = message.send(fail_silently=False) + logger.info("Email send results: sent_count=%s to=%s", sent_count, data["send_to"]) + + +email_client = EmailClient() diff --git a/app/core/models.py b/app/core/models.py index edad8fc..1aa98b3 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -18,8 +18,10 @@ def create(self, **kwargs: dict) -> models.Model: current_user = get_current_user() return super().create(**(kwargs | {"created_by": current_user, "updated_by": current_user})) - def update(self, **kwargs: dict) -> typing.Self: - return super().update(**(kwargs | {"updated_by": get_current_user()})) + def update(self, **kwargs: dict) -> int: + if "updated_by" not in kwargs and "updated_by_id" not in kwargs: + kwargs |= {"updated_by": get_current_user()} + return super().update(**kwargs) def delete(self) -> int: # type: ignore[override] return super().update(deleted_by=get_current_user(), deleted_at=Now()) @@ -30,6 +32,10 @@ def hard_delete(self) -> tuple[int, dict[str, int]]: def filter_active(self) -> typing.Self: return self.filter(deleted_at__isnull=True) + def select_related_with_user(self, *fields) -> typing.Self: + _fields = set(fields) | {"created_by", "updated_by", "deleted_by"} + return self.select_related(*_fields) + class BaseAbstractModel(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) diff --git a/app/core/openapi/schemas.py b/app/core/openapi/schemas.py index 065470a..3724853 100644 --- a/app/core/openapi/schemas.py +++ b/app/core/openapi/schemas.py @@ -1,5 +1,8 @@ -from drf_spectacular.openapi import AutoSchema +from drf_spectacular.openapi import AutoSchema, OpenApiExample, OpenApiResponse from drf_spectacular.utils import OpenApiParameter +from rest_framework import status + +HTML_EXAMPLE_STR = "" class BackendAutoSchema(AutoSchema): @@ -11,3 +14,16 @@ class BackendAutoSchema(AutoSchema): def get_override_parameters(self) -> list[OpenApiParameter]: return super().get_override_parameters() + self.global_params + + +def build_html_responses(names: list[str], status_code: int = status.HTTP_200_OK) -> dict[int, OpenApiResponse]: + examples = [ + OpenApiExample( + name=name, + media_type="text/html", + value=HTML_EXAMPLE_STR, + status_codes=[status_code], + ) + for name in names + ] + return {status_code: OpenApiResponse(response=str, examples=examples)} diff --git a/app/core/settings.py b/app/core/settings.py index 2afdfa9..6b66f9f 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -164,6 +164,7 @@ "event", "event.presentation", "event.sponsor", + "notification", "admin_api", "participant_portal_api", "external_api", @@ -197,7 +198,7 @@ TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": ["core/templates"], + "DIRS": ["core/templates", "notification/templates"], "APP_DIRS": True, "OPTIONS": { "context_processors": [ @@ -380,6 +381,34 @@ SCOPES=env.list("GOOGLE_OAUTH_SCOPES", default=[]), ) +# Email (Gmail OAuth2) Settings — host/port/SSL은 Gmail 전용 고정값. +# 로컬 개발은 envfile에서 EMAIL_BACKEND를 console 백엔드로 오버라이드. +EMAIL_BACKEND = env("EMAIL_BACKEND", default="core.email_backends.GmailOAuth2Backend") +EMAIL_HOST = "smtp.gmail.com" +EMAIL_PORT = 465 +EMAIL_USE_SSL = True +EMAIL_USE_TLS = False +EMAIL_HOST_USER = env("EMAIL_HOST_USER", default="") +EMAIL_TIMEOUT = env.float("EMAIL_TIMEOUT", default=30.0) + +# NHN Cloud Settings +# https://docs.nhncloud.com/ko/Notification/KakaoTalk%20Bizmessage/ko/alimtalk-api-guide/ +# https://docs.nhncloud.com/ko/Notification/SMS/ko/api-guide/ +NHN_CLOUD = types.SimpleNamespace( + app_key=env("NHN_CLOUD_APP_KEY", default=""), + secret_key=env("NHN_CLOUD_SECRET_KEY", default=""), + kakao_alimtalk=types.SimpleNamespace( + base_url=env( + "NHN_CLOUD_KAKAO_ALIMTALK_BASE_URL", default="https://kakaotalk-bizmessage.api.nhncloudservice.com" + ), + timeout=env.float("NHN_CLOUD_KAKAO_ALIMTALK_TIMEOUT", default=30.0), + ), + sms=types.SimpleNamespace( + base_url=env("NHN_CLOUD_SMS_BASE_URL", default="https://sms.api.nhncloudservice.com"), + timeout=env.float("NHN_CLOUD_SMS_TIMEOUT", default=30.0), + ), +) + # Sentry Settings if SENTRY_DSN := env("SENTRY_DSN", default=""): SENTRY_TRACES_SAMPLE_RATE = env.float("SENTRY_TRACES_SAMPLE_RATE", default=1.0) diff --git a/app/core/test/__init__.py b/app/core/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/test/email_backends_test.py b/app/core/test/email_backends_test.py new file mode 100644 index 0000000..18d2257 --- /dev/null +++ b/app/core/test/email_backends_test.py @@ -0,0 +1,142 @@ +import base64 +from datetime import UTC, datetime, timedelta +from smtplib import SMTPAuthenticationError +from unittest.mock import MagicMock, patch + +import pytest +from core.email_backends import ( + _ACCESS_TOKEN_TTL_BUFFER, + GmailOAuth2Backend, + _access_token_cache, +) +from external_api.google_oauth2.models import GoogleOAuth2 +from user.models import UserExt + + +@pytest.fixture +def system_user(db): + return UserExt.get_system_user() + + +@pytest.fixture +def google_oauth_record(system_user): + return GoogleOAuth2.objects.create( # nosec: B106 + refresh_token="rt-test", + created_by=system_user, + updated_by=system_user, + ) + + +@pytest.fixture(autouse=True) +def clear_access_token_cache(): + _access_token_cache.clear() + yield + _access_token_cache.clear() + + +@pytest.fixture +def mock_token_endpoint(): + """Google OAuth2 token endpoint를 mock — 기본 응답: tok-1, expires_in=3600.""" + with patch("core.email_backends.httpx_post") as mock: + response = MagicMock() + response.json.return_value = {"access_token": "tok-1", "expires_in": 3600} + response.raise_for_status.return_value = response + mock.return_value = response + yield mock + + +@pytest.fixture +def backend(): + return GmailOAuth2Backend() + + +@pytest.mark.django_db +class TestAccessTokenCaching: + def test_first_access_fetches_from_google(self, backend, google_oauth_record, mock_token_endpoint): + token = backend._access_token + assert token == "tok-1" # nosec: B105 + assert mock_token_endpoint.call_count == 1 + + def test_subsequent_access_uses_cache(self, backend, google_oauth_record, mock_token_endpoint): + backend._access_token # warm cache + backend._access_token + backend._access_token + # 한 번만 호출되어야 함 + assert mock_token_endpoint.call_count == 1 + + def test_expired_token_triggers_refresh(self, backend, google_oauth_record, mock_token_endpoint): + # 이미 만료된 캐시 entry 시뮬레이션 + _access_token_cache[google_oauth_record.refresh_token] = ( + "stale", + datetime.now(UTC) - timedelta(seconds=10), + ) + mock_token_endpoint.return_value.json.return_value = {"access_token": "tok-fresh", "expires_in": 3600} + + token = backend._access_token + + assert token == "tok-fresh" # nosec: B105 + assert mock_token_endpoint.call_count == 1 + + def test_token_cached_with_ttl_minus_buffer(self, backend, google_oauth_record, mock_token_endpoint): + before = datetime.now(UTC) + backend._access_token + after = datetime.now(UTC) + + _, expires_at = _access_token_cache[google_oauth_record.refresh_token] + # expires_at은 (now + 1h - 60s) 범위 안에 있어야 함 + assert before + timedelta(hours=1) - _ACCESS_TOKEN_TTL_BUFFER <= expires_at + assert expires_at <= after + timedelta(hours=1) - _ACCESS_TOKEN_TTL_BUFFER + + def test_default_ttl_when_expires_in_missing(self, backend, google_oauth_record, mock_token_endpoint): + # expires_in이 응답에 없으면 기본 1시간 사용 + mock_token_endpoint.return_value.json.return_value = {"access_token": "tok-x"} + backend._access_token + + _, expires_at = _access_token_cache[google_oauth_record.refresh_token] + approx = datetime.now(UTC) + timedelta(hours=1) - _ACCESS_TOKEN_TTL_BUFFER + assert abs((expires_at - approx).total_seconds()) < 5 + + def test_no_oauth_record_raises(self, backend): + with pytest.raises(RuntimeError, match="No GoogleOAuth2 refresh token configured"): + backend._access_token + + def test_uses_latest_active_record(self, system_user, backend, mock_token_endpoint): + rt_old = "rt-old" # nosec: B105 + rt_new = "rt-new" # nosec: B105 + GoogleOAuth2.objects.create(refresh_token=rt_old, created_by=system_user, updated_by=system_user) + GoogleOAuth2.objects.create(refresh_token=rt_new, created_by=system_user, updated_by=system_user) + + backend._access_token + + called_with = mock_token_endpoint.call_args.kwargs["data"] + assert called_with["refresh_token"] == rt_new + + +@pytest.mark.django_db +class TestAuthenticateXOAuth2: + def test_sasl_payload_format(self, backend, google_oauth_record, mock_token_endpoint): + backend.username = "user@example.com" + backend.connection = MagicMock() + backend.connection.docmd.return_value = (235, b"OK") + + backend._authenticate_xoauth2() + + cmd, payload = backend.connection.docmd.call_args.args + assert cmd == "AUTH" + assert payload.startswith("XOAUTH2 ") + len_prefix = len("XOAUTH2 ") + decoded = base64.b64decode(payload[len_prefix:]).decode() + assert decoded == "user=user@example.com\x01auth=Bearer tok-1\x01\x01" + + def test_non_235_response_raises_authentication_error(self, backend, google_oauth_record, mock_token_endpoint): + backend.username = "user@example.com" + backend.connection = MagicMock() + backend.connection.docmd.return_value = (535, b"Auth failed") + + with pytest.raises(SMTPAuthenticationError): + backend._authenticate_xoauth2() + + def test_missing_username_raises(self, backend): + backend.username = "" + with pytest.raises(SMTPAuthenticationError, match="EMAIL_HOST_USER"): + backend._authenticate_xoauth2() diff --git a/app/core/test/models_test.py b/app/core/test/models_test.py new file mode 100644 index 0000000..8896492 --- /dev/null +++ b/app/core/test/models_test.py @@ -0,0 +1,72 @@ +from unittest.mock import patch + +import pytest +from notification.models import EmailNotificationTemplate +from user.models import UserExt + + +@pytest.fixture +def system_user(db): + return UserExt.get_system_user() + + +@pytest.fixture +def other_user(db): + return UserExt.objects.create(username="other", email="other@example.com") + + +@pytest.fixture +def template(system_user): + return EmailNotificationTemplate.objects.create( + code="t", + title="t", + sent_from="a@b.c", + data='{"title":"x","from_":"f","send_to":"r","body":"b"}', + created_by=system_user, + updated_by=system_user, + ) + + +# `BaseAbstractModelQuerySet.update()`가 호출자가 명시한 `updated_by[_id]`를 보존해야 함을 보장. +# 회귀 케이스: Django의 `bulk_update`는 FK 컬럼명(`updated_by_id`)을 사용해 `update()`를 호출하는데, +# 오버라이드가 무조건 `updated_by`를 추가하면 같은 컬럼이 두 번 SET되어 PostgreSQL이 거부함. + + +@pytest.mark.django_db +def test_queryset_update_auto_injects_updated_by_when_not_specified(template, other_user): + # 호출자가 updated_by를 명시하지 않으면 `get_current_user()` 결과를 자동 주입 + with patch("core.models.get_current_user", return_value=other_user): + EmailNotificationTemplate.objects.filter(pk=template.pk).update(title="new") + template.refresh_from_db() + assert template.title == "new" + assert template.updated_by_id == other_user.id + + +@pytest.mark.django_db +def test_queryset_update_respects_explicit_updated_by_relation(template, other_user, system_user): + # 명시한 updated_by가 자동 주입에 의해 덮어쓰이면 안 됨 + with patch("core.models.get_current_user", return_value=system_user): + EmailNotificationTemplate.objects.filter(pk=template.pk).update(updated_by=other_user) + template.refresh_from_db() + assert template.updated_by_id == other_user.id + + +@pytest.mark.django_db +def test_queryset_update_respects_explicit_updated_by_id(template, other_user, system_user): + # FK 컬럼명(`*_id`) 형태로 명시한 경우에도 자동 주입을 건너뛰어야 함 — bulk_update 경로 + with patch("core.models.get_current_user", return_value=system_user): + EmailNotificationTemplate.objects.filter(pk=template.pk).update(updated_by_id=other_user.id) + template.refresh_from_db() + assert template.updated_by_id == other_user.id + + +@pytest.mark.django_db +def test_queryset_bulk_update_does_not_raise_duplicate_column(template, other_user): + # bulk_update는 내부적으로 `update(updated_by_id=Case(...))`를 호출 → 자동 주입과 충돌하면 안 됨 + template.title = "bulked" + template.updated_by = other_user + EmailNotificationTemplate.objects.bulk_update([template], fields=["title", "updated_by"]) + + template.refresh_from_db() + assert template.title == "bulked" + assert template.updated_by_id == other_user.id diff --git a/app/core/test/nhn_cloud_sms_test.py b/app/core/test/nhn_cloud_sms_test.py new file mode 100644 index 0000000..b099c87 --- /dev/null +++ b/app/core/test/nhn_cloud_sms_test.py @@ -0,0 +1,103 @@ +import logging +from unittest.mock import MagicMock, patch + +import pytest +from core.external_apis.__interface__ import SendParameters +from core.external_apis.nhn_cloud_sms import nhn_cloud_sms_client + + +@pytest.fixture +def mock_session(): + """싱글톤 클라이언트의 httpx 세션을 mock으로 교체. NHN 표준 성공 응답을 기본값으로 반환.""" + with patch.object(nhn_cloud_sms_client, "session") as session: + response = MagicMock() + response.raise_for_status.return_value = response + response.json.return_value = { + "header": {"isSuccessful": True, "resultCode": 0, "resultMessage": "SUCCESS"}, + "body": {"data": {"requestId": "REQ-1", "statusCode": "2", "sendResultList": []}}, + } + session.post.return_value = response + yield session + + +def _params(**overrides) -> SendParameters: + return SendParameters( + payload=overrides.pop("payload", {"body": "Hello"}), + send_to=overrides.pop("send_to", "01012345678"), + sent_from=overrides.pop("sent_from", "0212345678"), + template_code=overrides.pop("template_code", ""), + ) + + +# ---- send_message — 입력 검증 ----------------------------------------------- + + +def test_send_message_raises_if_sent_from_is_none(): + with pytest.raises(ValueError, match="sent_from"): + nhn_cloud_sms_client.send_message(data=_params(sent_from=None)) + + +def test_send_message_raises_if_sent_from_is_empty(): + with pytest.raises(ValueError, match="sent_from"): + nhn_cloud_sms_client.send_message(data=_params(sent_from="")) + + +def test_send_message_raises_if_body_missing_from_payload(): + with pytest.raises(ValueError, match="body"): + nhn_cloud_sms_client.send_message(data=_params(payload={"title": "only title"})) + + +def test_send_message_raises_if_body_is_empty_string(): + with pytest.raises(ValueError, match="body"): + nhn_cloud_sms_client.send_message(data=_params(payload={"body": ""})) + + +# ---- send_message — 단문 SMS / 장문 MMS 분기 --------------------------------- + + +def test_send_message_short_sms_hits_sender_sms_endpoint(mock_session): + nhn_cloud_sms_client.send_message(data=_params(payload={"body": "Hello"})) + + mock_session.post.assert_called_once_with( + "/sender/sms", + json={ + "sendNo": "0212345678", + "body": "Hello", + "recipientList": [{"recipientNo": "01012345678"}], + }, + ) + + +def test_send_message_long_mms_hits_sender_mms_endpoint_when_title_present(mock_session): + nhn_cloud_sms_client.send_message(data=_params(payload={"title": "공지", "body": "본문"})) + + mock_session.post.assert_called_once_with( + "/sender/mms", + json={ + "sendNo": "0212345678", + "body": "본문", + "recipientList": [{"recipientNo": "01012345678"}], + "title": "공지", + }, + ) + + +def test_send_message_template_code_passed_as_template_id_when_truthy(mock_session): + nhn_cloud_sms_client.send_message(data=_params(template_code="TEMPLATE-1")) + + sent_body = mock_session.post.call_args.kwargs["json"] + assert sent_body["templateId"] == "TEMPLATE-1" + + +def test_send_message_template_id_omitted_when_template_code_empty(mock_session): + nhn_cloud_sms_client.send_message(data=_params(template_code="")) + + sent_body = mock_session.post.call_args.kwargs["json"] + assert "templateId" not in sent_body + + +def test_send_message_logs_result_code_and_message_on_success(mock_session, caplog): + with caplog.at_level(logging.INFO, logger="core.external_apis.nhn_cloud_sms"): + nhn_cloud_sms_client.send_message(data=_params()) + + assert any("result_code=0" in r.getMessage() and "SUCCESS" in r.getMessage() for r in caplog.records) diff --git a/app/core/util/google_api.py b/app/core/util/google_api.py index 150e8eb..da62351 100644 --- a/app/core/util/google_api.py +++ b/app/core/util/google_api.py @@ -27,12 +27,13 @@ def create_oauth_flow() -> Flow | None: def create_authorization_url( flow: Flow, prompt: str = "consent", access_type: str = "offline", include_granted_scopes: bool = False -) -> str: - return flow.authorization_url( +) -> tuple[str, str | None, str]: + url, state = flow.authorization_url( prompt=prompt, access_type=access_type, include_granted_scopes="true" if include_granted_scopes else "false", - )[0] + ) + return url, flow.code_verifier, state def fetch_credentials(flow: Flow, code: str) -> Credentials: diff --git a/app/external_api/google_oauth2/views.py b/app/external_api/google_oauth2/views.py index c0358dc..2f18f8b 100644 --- a/app/external_api/google_oauth2/views.py +++ b/app/external_api/google_oauth2/views.py @@ -45,20 +45,30 @@ def _response_500(detail: str) -> response.Response: ) @decorators.action(detail=False, methods=["get"], url_path="authorize", url_name="authorize") - def authorize_google_oauth2(self, *args: tuple, **kwargs: dict) -> response.Response: + def authorize_google_oauth2(self, request: request.Request, *args, **kwargs) -> response.Response: if not (flow := create_oauth_flow()): return self._response_500("Google OAuth is not configured.") - return redirect(create_authorization_url(flow=flow)) + url, code_verifier, state = create_authorization_url(flow=flow) + request.session["google_oauth2_code_verifier"] = code_verifier + request.session["google_oauth2_state"] = state + return redirect(url) @decorators.action(detail=False, methods=["get"], url_path="redirect", url_name="redirect") def redirect_google_oauth2(self, request: request.Request, *args, **kwargs) -> response.Response: if not (code := request.query_params.get("code")): return self._response_400("missing_code", "The 'code' query parameter is required.") + expected_state = request.session.pop("google_oauth2_state", None) + received_state = request.query_params.get("state") + if not expected_state or expected_state != received_state: + return self._response_400("invalid_state", "The 'state' parameter is missing or does not match.") + if not (flow := create_oauth_flow()): return self._response_500("Google OAuth is not configured.") + flow.code_verifier = request.session.pop("google_oauth2_code_verifier", None) + try: refresh_token = fetch_credentials(flow, code).refresh_token system = UserExt.get_system_user() diff --git a/app/notification/__init__.py b/app/notification/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/notification/apps.py b/app/notification/apps.py new file mode 100644 index 0000000..e30beee --- /dev/null +++ b/app/notification/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class NotificationConfig(AppConfig): + name = "notification" diff --git a/app/notification/migrations/0001_initial.py b/app/notification/migrations/0001_initial.py new file mode 100644 index 0000000..93456c9 --- /dev/null +++ b/app/notification/migrations/0001_initial.py @@ -0,0 +1,559 @@ +# Generated by Django 6.0.4 on 2026-04-26 10:04 + +from uuid import uuid4 + +from django.conf import settings +from django.db import migrations, models +from django.db.models.deletion import PROTECT + + +class Migration(migrations.Migration): + initial = True + dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] + operations = [ + migrations.CreateModel( + name="EmailNotificationTemplate", + fields=[ + ("id", models.UUIDField(default=uuid4, editable=False, primary_key=True, serialize=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("code", models.CharField(max_length=128)), + ("title", models.CharField(db_index=True, max_length=256)), + ("description", models.TextField(blank=True, null=True)), + ("data", models.TextField()), + ("sent_from", models.CharField(max_length=256)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=PROTECT, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "deleted_by", + models.ForeignKey( + null=True, + on_delete=PROTECT, + related_name="%(class)s_deleted_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=PROTECT, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="EmailNotificationHistory", + fields=[ + ("id", models.UUIDField(default=uuid4, editable=False, primary_key=True, serialize=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("template_data", models.TextField()), + ("sent_from", models.CharField(max_length=256)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=PROTECT, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "deleted_by", + models.ForeignKey( + null=True, + on_delete=PROTECT, + related_name="%(class)s_deleted_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=PROTECT, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "template", + models.ForeignKey( + blank=True, + null=True, + on_delete=PROTECT, + related_name="histories", + to="notification.emailnotificationtemplate", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="NHNCloudKakaoAlimTalkNotificationHistory", + fields=[ + ("id", models.UUIDField(default=uuid4, editable=False, primary_key=True, serialize=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("template_data", models.TextField()), + ("sent_from", models.CharField(max_length=256)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=PROTECT, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "deleted_by", + models.ForeignKey( + null=True, + on_delete=PROTECT, + related_name="%(class)s_deleted_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=PROTECT, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="NHNCloudKakaoAlimTalkNotificationHistorySentTo", + fields=[ + ("id", models.UUIDField(default=uuid4, editable=False, primary_key=True, serialize=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("recipient", models.CharField(max_length=256)), + ("context", models.JSONField(default=dict)), + ( + "status", + models.CharField( + choices=[ + ("CREATED", "Created"), + ("SENDING", "Sending"), + ("SENT", "Sent"), + ("FAILED", "Failed"), + ], + db_index=True, + default="CREATED", + max_length=16, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=PROTECT, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "deleted_by", + models.ForeignKey( + null=True, + on_delete=PROTECT, + related_name="%(class)s_deleted_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "history", + models.ForeignKey( + on_delete=PROTECT, + related_name="sent_to_list", + to="notification.nhncloudkakaoalimtalknotificationhistory", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=PROTECT, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="NHNCloudKakaoAlimTalkNotificationTemplate", + fields=[ + ("id", models.UUIDField(default=uuid4, editable=False, primary_key=True, serialize=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("code", models.CharField(max_length=128)), + ("title", models.CharField(db_index=True, max_length=256)), + ("description", models.TextField(blank=True, null=True)), + ("data", models.TextField()), + ("sent_from", models.CharField(max_length=256)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=PROTECT, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "deleted_by", + models.ForeignKey( + null=True, + on_delete=PROTECT, + related_name="%(class)s_deleted_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=PROTECT, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddField( + model_name="nhncloudkakaoalimtalknotificationhistory", + name="template", + field=models.ForeignKey( + on_delete=PROTECT, + related_name="histories", + to="notification.nhncloudkakaoalimtalknotificationtemplate", + ), + ), + migrations.CreateModel( + name="NHNCloudSMSNotificationHistory", + fields=[ + ("id", models.UUIDField(default=uuid4, editable=False, primary_key=True, serialize=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("template_data", models.TextField()), + ("sent_from", models.CharField(max_length=256)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=PROTECT, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "deleted_by", + models.ForeignKey( + null=True, + on_delete=PROTECT, + related_name="%(class)s_deleted_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=PROTECT, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="NHNCloudSMSNotificationHistorySentTo", + fields=[ + ("id", models.UUIDField(default=uuid4, editable=False, primary_key=True, serialize=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("recipient", models.CharField(max_length=256)), + ("context", models.JSONField(default=dict)), + ( + "status", + models.CharField( + choices=[ + ("CREATED", "Created"), + ("SENDING", "Sending"), + ("SENT", "Sent"), + ("FAILED", "Failed"), + ], + db_index=True, + default="CREATED", + max_length=16, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=PROTECT, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "deleted_by", + models.ForeignKey( + null=True, + on_delete=PROTECT, + related_name="%(class)s_deleted_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "history", + models.ForeignKey( + on_delete=PROTECT, + related_name="sent_to_list", + to="notification.nhncloudsmsnotificationhistory", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=PROTECT, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="NHNCloudSMSNotificationTemplate", + fields=[ + ("id", models.UUIDField(default=uuid4, editable=False, primary_key=True, serialize=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("code", models.CharField(max_length=128)), + ("title", models.CharField(db_index=True, max_length=256)), + ("description", models.TextField(blank=True, null=True)), + ("data", models.TextField()), + ("sent_from", models.CharField(max_length=256)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=PROTECT, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "deleted_by", + models.ForeignKey( + null=True, + on_delete=PROTECT, + related_name="%(class)s_deleted_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=PROTECT, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddField( + model_name="nhncloudsmsnotificationhistory", + name="template", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=PROTECT, + related_name="histories", + to="notification.nhncloudsmsnotificationtemplate", + ), + ), + migrations.CreateModel( + name="EmailNotificationHistorySentTo", + fields=[ + ("id", models.UUIDField(default=uuid4, editable=False, primary_key=True, serialize=False)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("recipient", models.CharField(max_length=256)), + ("context", models.JSONField(default=dict)), + ( + "status", + models.CharField( + choices=[ + ("CREATED", "Created"), + ("SENDING", "Sending"), + ("SENT", "Sent"), + ("FAILED", "Failed"), + ], + db_index=True, + default="CREATED", + max_length=16, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=PROTECT, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "deleted_by", + models.ForeignKey( + null=True, + on_delete=PROTECT, + related_name="%(class)s_deleted_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "history", + models.ForeignKey( + on_delete=PROTECT, + related_name="sent_to_list", + to="notification.emailnotificationhistory", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=PROTECT, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "abstract": False, + "constraints": [ + models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("history", "recipient"), + name="uq_notification_emailnotificationhistorysentto_history_recipient", + ) + ], + }, + ), + migrations.AddConstraint( + model_name="emailnotificationtemplate", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("code",), + name="uq_notification_emailnotificationtemplate_code", + ), + ), + migrations.AddConstraint( + model_name="emailnotificationtemplate", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("code", "title"), + name="uq_notification_emailnotificationtemplate_code_title", + ), + ), + migrations.AddConstraint( + model_name="nhncloudkakaoalimtalknotificationhistorysentto", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("history", "recipient"), + name="uq_notification_nhncloudkakaoalimtalknotificationhistorysentto_history_recipient", + ), + ), + migrations.AddConstraint( + model_name="nhncloudkakaoalimtalknotificationtemplate", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("code",), + name="uq_notification_nhncloudkakaoalimtalknotificationtemplate_code", + ), + ), + migrations.AddConstraint( + model_name="nhncloudkakaoalimtalknotificationtemplate", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("code", "title"), + name="uq_notification_nhncloudkakaoalimtalknotificationtemplate_code_title", + ), + ), + migrations.AddConstraint( + model_name="nhncloudsmsnotificationhistorysentto", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("history", "recipient"), + name="uq_notification_nhncloudsmsnotificationhistorysentto_history_recipient", + ), + ), + migrations.AddConstraint( + model_name="nhncloudsmsnotificationtemplate", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("code",), + name="uq_notification_nhncloudsmsnotificationtemplate_code", + ), + ), + migrations.AddConstraint( + model_name="nhncloudsmsnotificationtemplate", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("code", "title"), + name="uq_notification_nhncloudsmsnotificationtemplate_code_title", + ), + ), + ] diff --git a/app/notification/models/__init__.py b/app/notification/models/__init__.py new file mode 100644 index 0000000..6ca5c08 --- /dev/null +++ b/app/notification/models/__init__.py @@ -0,0 +1,26 @@ +from .base import Recipient, UnhandledVariableHandling +from .email import EmailNotificationHistory, EmailNotificationHistorySentTo, EmailNotificationTemplate +from .nhn_cloud_kakao_alimtalk import ( + NHNCloudKakaoAlimTalkNotificationHistory, + NHNCloudKakaoAlimTalkNotificationHistorySentTo, + NHNCloudKakaoAlimTalkNotificationTemplate, +) +from .nhn_cloud_sms import ( + NHNCloudSMSNotificationHistory, + NHNCloudSMSNotificationHistorySentTo, + NHNCloudSMSNotificationTemplate, +) + +__all__ = [ + "EmailNotificationHistory", + "EmailNotificationHistorySentTo", + "EmailNotificationTemplate", + "NHNCloudKakaoAlimTalkNotificationHistory", + "NHNCloudKakaoAlimTalkNotificationHistorySentTo", + "NHNCloudKakaoAlimTalkNotificationTemplate", + "NHNCloudSMSNotificationHistory", + "NHNCloudSMSNotificationHistorySentTo", + "NHNCloudSMSNotificationTemplate", + "Recipient", + "UnhandledVariableHandling", +] diff --git a/app/notification/models/base.py b/app/notification/models/base.py new file mode 100644 index 0000000..a6b7d52 --- /dev/null +++ b/app/notification/models/base.py @@ -0,0 +1,301 @@ +from enum import StrEnum, auto +from json import loads as json_loads +from logging import getLogger +from typing import TYPE_CHECKING, Any, ClassVar, Generic, NotRequired, TypedDict, TypeVar +from uuid import uuid4 + +from core.external_apis.__interface__ import NotificationServiceInterface, SendParameters +from core.models import BaseAbstractModel, BaseAbstractModelQuerySet +from django.db import models, transaction +from django.template import Context, Template +from django.template.base import VariableNode +from django.template.loader import get_template + +if TYPE_CHECKING: + from django.db.models.manager import RelatedManager + +slack_logger = getLogger("slack_logger") + + +class UnhandledVariableHandling(StrEnum): + RAISE = auto() + RANDOM = auto() + SHOW_AS_TEMPLATE_VAR = auto() + REMOVE = auto() + + +class NotificationStatus(models.TextChoices): + CREATED = "CREATED" + SENDING = "SENDING" + SENT = "SENT" + FAILED = "FAILED" + + +class Recipient(TypedDict): + recipient: str + context: NotRequired[dict[str, Any]] + + +def _walk_strings(value: Any, fn: Any) -> Any: + # JSON 트리 안의 string node에만 fn을 적용. dict key와 non-string scalar는 그대로 보존. + if isinstance(value, str): + return fn(value) + if isinstance(value, dict): + return {k: _walk_strings(v, fn) for k, v in value.items()} + if isinstance(value, list): + return [_walk_strings(v, fn) for v in value] + return value + + +class NotificationTemplateBase(BaseAbstractModel): + variable_start: ClassVar[str] = "{{" + variable_end: ClassVar[str] = "}}" + html_template_name: ClassVar[str] + + code = models.CharField(max_length=128) + title = models.CharField(max_length=256, db_index=True) + description = models.TextField(null=True, blank=True) + data = models.TextField() + + # Email: from address, SMS: 발신번호, Kakao: sender key + sent_from = models.CharField(max_length=256) + + class Meta: + abstract = True + constraints = [ + models.UniqueConstraint( + fields=["code"], + condition=models.Q(deleted_at__isnull=True), + name="uq_%(app_label)s_%(class)s_code", + ), + models.UniqueConstraint( + fields=["code", "title"], + condition=models.Q(deleted_at__isnull=True), + name="uq_%(app_label)s_%(class)s_code_title", + ), + ] + + @classmethod + def _to_dtl(cls, source: str) -> str: + return source + + @classmethod + def _extract_root_variables(cls, source: str) -> set[str]: + roots: set[str] = set() + for node in Template(cls._to_dtl(source)).nodelist.get_nodes_by_type(VariableNode): + var = node.filter_expression.var + if not hasattr(var, "literal") or var.literal is not None: + continue + roots.add(str(var.var).split(".", 1)[0]) + return roots + + @property + def template_variables(self) -> set[str]: + # template_data를 JSON 트리로 파싱한 뒤 모든 string value에서 변수를 수집해 union. + # template_data가 유효한 JSON이 아니면 단일 문자열로 처리. + try: + parsed = json_loads(self.data) + except ValueError: + return type(self)._extract_root_variables(self.data) + + all_vars: set[str] = set() + + def collect(s: str) -> str: + all_vars.update(type(self)._extract_root_variables(s)) + return s + + _walk_strings(parsed, collect) + return all_vars + + def build_preview_sent_to(self, context: dict[str, Any]) -> "NotificationHistorySentToBase": + # admin 미리보기용 transient (unsaved) 객체 — 발송 path와 동일한 SentTo.render 경로 사용. + # template→history reverse relation에서 채널의 History 클래스 동적 dispatch. + history_class: type[NotificationHistoryBase] = type(self)._meta.get_field("histories").related_model + history = history_class(template=self, template_data=self.data, sent_from=self.sent_from) + return history.sent_to_class(history=history, context=context) + + +THistory = TypeVar("THistory", bound="NotificationHistoryBase") +TTemplate = TypeVar("TTemplate", bound=NotificationTemplateBase) + + +class NotificationHistoryQuerySet(BaseAbstractModelQuerySet, Generic[THistory, TTemplate]): + @transaction.atomic + def create_for_recipients(self, *, template: TTemplate, recipients: list[Recipient]) -> THistory: + # template은 항상 필수. templateless 발송 시 호출자가 unsaved 인스턴스를 구성해 전달. + # 저장된 template만 FK로 연결, snapshot은 template.data/sent_from에서 추출. + if not template.data or not template.sent_from: + raise ValueError("template.data와 template.sent_from이 모두 필요합니다.") + + history: THistory = self.create( + # unsaved (transient) template은 FK로 연결하지 않음 — id가 default uuid4로 채워지므로 _state.adding으로 판별. + template=None if template._state.adding else template, + template_data=template.data, + sent_from=template.sent_from, + ) + + sent_to_class = self.model.sent_to_class + sent_to_class.objects.bulk_create([sent_to_class(history=history, **r) for r in recipients]) + return history + + +class NotificationHistoryBase(BaseAbstractModel): + client: ClassVar[NotificationServiceInterface] + + template_class: ClassVar[type[NotificationTemplateBase]] + sent_to_class: ClassVar[type["NotificationHistorySentToBase"]] + template_data = models.TextField() + sent_from = models.CharField(max_length=256) + + sent_to_list: "RelatedManager[NotificationHistorySentToBase]" + + class Meta: + abstract = True + + @property + def template_code(self) -> str: + return self.template.code if self.template_id else "" + + @property + def sent_to_status_summary(self) -> dict[str, int]: + # prefetch된 sent_to_list가 있으면 in-memory 집계 (list 엔드포인트에서 history 당 GROUP BY 회피), + # 없으면 GROUP BY 한 번으로 카운트. + prefetched = getattr(self, "_prefetched_objects_cache", {}) + if "sent_to_list" in prefetched: + counts: dict[str, int] = {} + for s in self.sent_to_list.all(): + counts[s.status] = counts.get(s.status, 0) + 1 + else: + counts = dict(self.sent_to_list.values("status").annotate(n=models.Count("*")).values_list("status", "n")) + return {status.value.lower(): counts.get(status.value, 0) for status in NotificationStatus} + + def _send_each(self, sent_to_qs: "models.QuerySet[NotificationHistorySentToBase]") -> None: + # sent_to.history reverse FK가 매번 재조회되지 않도록 self를 직접 attach (N+1 회피). + for sent_to in sent_to_qs: + sent_to.history = self + try: + sent_to.send() + except Exception: + # 내부 send()가 catch+log하지 못한 경로(상태 저장 실패 등)는 status가 FAILED로 남지 않음 — 추가 로깅. + if sent_to.status != NotificationStatus.FAILED: + slack_logger.exception( + "Batch send unexpected error: history_id=%s recipient=%s", + self.id, + sent_to.recipient, + ) + + def send(self) -> None: + self._send_each(self.sent_to_list.all()) + + def retry(self) -> None: + self._send_each(self.sent_to_list.filter(status=NotificationStatus.FAILED)) + + +class NotificationHistorySentToBase(BaseAbstractModel): + history: models.ForeignKey[NotificationHistoryBase] + + recipient = models.CharField(max_length=256) + context = models.JSONField(default=dict) + status = models.CharField( + max_length=16, + choices=NotificationStatus.choices, + default=NotificationStatus.CREATED, + db_index=True, + ) + + class Meta: + abstract = True + constraints = [ + models.UniqueConstraint( + fields=["history", "recipient"], + condition=models.Q(deleted_at__isnull=True), + name="uq_%(app_label)s_%(class)s_history_recipient", + ), + ] + + def _parsed_template_data(self) -> Any: + try: + return json_loads(self.history.template_data) + except ValueError: + return self.history.template_data + + def _required_template_variables(self, payload: Any) -> set[str]: + template_class = self.history.template_class + all_vars: set[str] = set() + _walk_strings(payload, lambda s: all_vars.update(template_class._extract_root_variables(s)) or s) + return all_vars + + def assert_context_complete(self) -> None: + # render()를 거치지 않는 채널(예: Kakao templateParameter)에서도 외부 호출 전에 fail-fast 보장. + missing = self._required_template_variables(self._parsed_template_data()) - self.context.keys() + if missing: + raise ValueError( + f"Notification (template_code={self.history.template_code or '-'}) rendered " + f"without required context variables: {sorted(missing)}", + ) + + def render(self, undef_var: UnhandledVariableHandling = UnhandledVariableHandling.RAISE) -> dict[str, Any]: + # template_data를 JSON으로 먼저 파싱한 뒤 string value에만 Django Template을 적용 → + # context가 JSON-special char(`"`, `\`, 줄바꿈 등)를 포함해도 결과 JSON 구조가 깨지지 않음. + # autoescape=False — 외부 채널(SMS, Kakao templateParameter)은 raw text 기대. HTML escape이 필요한 경우는 + # template 작성자가 명시적으로 |escape 필터를 사용해야 함. + template_class = self.history.template_class + payload = self._parsed_template_data() + + rendered_context = dict(self.context) + missing = self._required_template_variables(payload) - rendered_context.keys() + + if missing and undef_var is UnhandledVariableHandling.RAISE: + raise ValueError( + f"Notification (template_code={self.history.template_code or '-'}) rendered " + f"without required context variables: {sorted(missing)}", + ) + + for key in missing: + match undef_var: + case UnhandledVariableHandling.SHOW_AS_TEMPLATE_VAR: + rendered_context[key] = f"{template_class.variable_start} {key} {template_class.variable_end}" + case UnhandledVariableHandling.RANDOM: + rendered_context[key] = f"RandomValue-{uuid4().hex[:8]}" + case UnhandledVariableHandling.REMOVE: + rendered_context[key] = "" + + ctx = Context(rendered_context, autoescape=False) + return _walk_strings(payload, lambda s: Template(template_class._to_dtl(s)).render(ctx)) + + def render_as_html(self, undef_var: UnhandledVariableHandling = UnhandledVariableHandling.RANDOM) -> str: + return get_template(self.history.template_class.html_template_name).render(self.render(undef_var=undef_var)) + + @property + def payload(self) -> dict[str, Any]: + # 채널별 외부 API에 보낼 payload — 기본은 snapshot template + context를 render한 결과. + # Kakao처럼 raw context를 그대로 넘겨야 하면 subclass에서 override. + return self.render() + + def build_send_parameters(self) -> SendParameters: + return SendParameters( + payload=self.payload, + send_to=self.recipient, + template_code=self.history.template_code, + sent_from=self.history.sent_from, + ) + + def send(self) -> None: + self.status = NotificationStatus.SENDING + self.save(update_fields=["status"]) + + try: + self.history.client.send_message(data=self.build_send_parameters()) + except Exception: + self.status = NotificationStatus.FAILED + self.save(update_fields=["status"]) + slack_logger.exception( + "Notification send failed: history_id=%s template_code=%s recipient=%s", + self.history.id, + self.history.template_code, + self.recipient, + ) + raise + + self.status = NotificationStatus.SENT + self.save(update_fields=["status"]) diff --git a/app/notification/models/email.py b/app/notification/models/email.py new file mode 100644 index 0000000..fe0fc6d --- /dev/null +++ b/app/notification/models/email.py @@ -0,0 +1,42 @@ +from typing import ClassVar + +from core.external_apis.smtp_email import EmailClient, email_client +from django.db import models +from notification.models.base import ( + NotificationHistoryBase, + NotificationHistoryQuerySet, + NotificationHistorySentToBase, + NotificationTemplateBase, +) + + +class EmailNotificationTemplate(NotificationTemplateBase): + html_template_name: ClassVar[str] = "email_preview.html" + + +class EmailNotificationHistorySentTo(NotificationHistorySentToBase): + history = models.ForeignKey("EmailNotificationHistory", on_delete=models.PROTECT, related_name="sent_to_list") + + +class EmailNotificationHistoryQuerySet( + NotificationHistoryQuerySet["EmailNotificationHistory", EmailNotificationTemplate], +): + pass + + +class EmailNotificationHistory(NotificationHistoryBase): + client: ClassVar[EmailClient] = email_client + template_class: ClassVar[type[EmailNotificationTemplate]] = EmailNotificationTemplate + sent_to_class: ClassVar[type[EmailNotificationHistorySentTo]] = EmailNotificationHistorySentTo + + template = models.ForeignKey( + EmailNotificationTemplate, + on_delete=models.PROTECT, + related_name="histories", + null=True, + blank=True, + ) + + objects: EmailNotificationHistoryQuerySet = ( + EmailNotificationHistoryQuerySet.as_manager() # type: ignore[misc, assignment] + ) diff --git a/app/notification/models/nhn_cloud_kakao_alimtalk.py b/app/notification/models/nhn_cloud_kakao_alimtalk.py new file mode 100644 index 0000000..95f92d8 --- /dev/null +++ b/app/notification/models/nhn_cloud_kakao_alimtalk.py @@ -0,0 +1,152 @@ +from re import compile as re_compile +from typing import Any, ClassVar, Self + +from core.external_apis.nhn_cloud_kakao_alimtalk import NHNCloudKakaoAlimTalkClient, nhn_cloud_kakao_alimtalk_client +from core.logger.util.django_helper import default_json_dumps +from core.models import BaseAbstractModelQuerySet +from django.db import models, transaction +from django.utils import timezone +from notification.models.base import ( + NotificationHistoryBase, + NotificationHistoryQuerySet, + NotificationHistorySentToBase, + NotificationTemplateBase, +) +from user.models import UserExt + +_KAKAO_VAR_RE = re_compile(r"#\{(\w+)\}") +_NHN_APPROVED_STATUS = "TSC03" +_READ_ONLY_MSG = ( + "NHN Cloud 알림톡 템플릿은 NHN Cloud Console에서 관리되므로 로컬에서 직접 생성/수정/삭제할 수 없습니다. " + "외부 변경사항을 반영하려면 sync_with_nhn_cloud()를 사용하세요." +) + + +class NHNCloudKakaoAlimTalkNotificationTemplateQuerySet(BaseAbstractModelQuerySet): + def create(self, *args: Any, **kwargs: Any) -> models.Model: + raise NotImplementedError(_READ_ONLY_MSG) + + def bulk_create(self, *args: Any, **kwargs: Any) -> list[models.Model]: + raise NotImplementedError(_READ_ONLY_MSG) + + def update(self, *args: Any, **kwargs: Any) -> int: + raise NotImplementedError(_READ_ONLY_MSG) + + def delete(self) -> int: # type: ignore[override] + raise NotImplementedError(_READ_ONLY_MSG) + + def sync_with_nhn_cloud(self) -> Self: + sender_keys = [s["senderKey"] for s in nhn_cloud_kakao_alimtalk_client.get_sender_list()["senders"]] + external_payloads = [] + for sk in sender_keys: + response = nhn_cloud_kakao_alimtalk_client.list_templates(sender_key=sk, pageSize=1000) + external_payloads.extend(response["templateListResponse"]["templates"]) + + external_by_code = {t["templateCode"]: t for t in external_payloads if t["status"] == _NHN_APPROVED_STATUS} + local_by_code = {t.code: t for t in self.filter_active()} + + with transaction.atomic(): + # 차단되지 않는 부모 queryset 인스턴스. bulk_update 내부의 `queryset.filter(...).update()`도 + # `_clone`이 `self.__class__`를 보존하므로 본 클래스의 차단을 우회하려면 부모 인스턴스를 사용해야 함. + unblocked = BaseAbstractModelQuerySet(model=self.model) + now, system_user = timezone.now(), UserExt.get_system_user() + + unblocked.bulk_create( + [ + NHNCloudKakaoAlimTalkNotificationTemplate( + code=code, + title=ext["templateName"], + description="", + sent_from=ext["senderKey"], + data=default_json_dumps(ext), + created_by=system_user, + updated_by=system_user, + ) + for code, ext in external_by_code.items() + if code not in local_by_code + ], + ) + + updated_rows = [] + for code, ext in external_by_code.items(): + if (row := local_by_code.get(code)) is None: + continue + + new_data = default_json_dumps(ext) + if row.title != ext["templateName"] or row.sent_from != ext["senderKey"] or row.data != new_data: + row.title = ext["templateName"] + row.sent_from = ext["senderKey"] + row.data = new_data + row.updated_at = now + row.updated_by = system_user + updated_rows.append(row) + unblocked.bulk_update( + updated_rows, + fields=["title", "sent_from", "data", "updated_at", "updated_by"], + batch_size=100, + ) + + unblocked.filter(id__in=[r.id for c, r in local_by_code.items() if c not in external_by_code]).delete() + + return self.filter_active() + + +class NHNCloudKakaoAlimTalkNotificationTemplate(NotificationTemplateBase): + variable_start: ClassVar[str] = "#{" + variable_end: ClassVar[str] = "}" + html_template_name: ClassVar[str] = "nhn_cloud_kakao_alimtalk_preview.html" + + objects: NHNCloudKakaoAlimTalkNotificationTemplateQuerySet = ( + NHNCloudKakaoAlimTalkNotificationTemplateQuerySet.as_manager() # type: ignore[misc, assignment] + ) + + def save(self, *args: Any, **kwargs: Any) -> None: + raise NotImplementedError(_READ_ONLY_MSG) + + def delete(self, *args: Any, **kwargs: Any) -> None: + raise NotImplementedError(_READ_ONLY_MSG) + + @classmethod + def _to_dtl(cls, source: str) -> str: + return _KAKAO_VAR_RE.sub(r"{{ \1 }}", source) + + +class NHNCloudKakaoAlimTalkNotificationHistorySentTo(NotificationHistorySentToBase): + history = models.ForeignKey( + "NHNCloudKakaoAlimTalkNotificationHistory", + on_delete=models.PROTECT, + related_name="sent_to_list", + ) + + @property + def payload(self) -> dict[str, Any]: + # Kakao 외부 API는 templateParameter dict를 그대로 받으므로 로컬 render 없이 self.context 사용. + # (render() 자체는 admin 미리보기용으로만 사용됨.) 단, render를 우회하므로 외부 호출 전에 변수 누락만은 명시 검증. + self.assert_context_complete() + return self.context + + +class NHNCloudKakaoAlimTalkNotificationHistoryQuerySet( + NotificationHistoryQuerySet["NHNCloudKakaoAlimTalkNotificationHistory", NHNCloudKakaoAlimTalkNotificationTemplate], +): + pass + + +class NHNCloudKakaoAlimTalkNotificationHistory(NotificationHistoryBase): + client: ClassVar[NHNCloudKakaoAlimTalkClient] = nhn_cloud_kakao_alimtalk_client + template_class: ClassVar[type[NHNCloudKakaoAlimTalkNotificationTemplate]] = ( + NHNCloudKakaoAlimTalkNotificationTemplate + ) + sent_to_class: ClassVar[type[NHNCloudKakaoAlimTalkNotificationHistorySentTo]] = ( + NHNCloudKakaoAlimTalkNotificationHistorySentTo + ) + + template = models.ForeignKey( + NHNCloudKakaoAlimTalkNotificationTemplate, + on_delete=models.PROTECT, + related_name="histories", + ) + + objects: NHNCloudKakaoAlimTalkNotificationHistoryQuerySet = ( + NHNCloudKakaoAlimTalkNotificationHistoryQuerySet.as_manager() # type: ignore[misc, assignment] + ) diff --git a/app/notification/models/nhn_cloud_sms.py b/app/notification/models/nhn_cloud_sms.py new file mode 100644 index 0000000..f963709 --- /dev/null +++ b/app/notification/models/nhn_cloud_sms.py @@ -0,0 +1,42 @@ +from typing import ClassVar + +from core.external_apis.nhn_cloud_sms import NHNCloudSMSClient, nhn_cloud_sms_client +from django.db import models +from notification.models.base import ( + NotificationHistoryBase, + NotificationHistoryQuerySet, + NotificationHistorySentToBase, + NotificationTemplateBase, +) + + +class NHNCloudSMSNotificationTemplate(NotificationTemplateBase): + html_template_name: ClassVar[str] = "nhn_cloud_sms_preview.html" + + +class NHNCloudSMSNotificationHistorySentTo(NotificationHistorySentToBase): + history = models.ForeignKey("NHNCloudSMSNotificationHistory", on_delete=models.PROTECT, related_name="sent_to_list") + + +class NHNCloudSMSNotificationHistoryQuerySet( + NotificationHistoryQuerySet["NHNCloudSMSNotificationHistory", NHNCloudSMSNotificationTemplate], +): + pass + + +class NHNCloudSMSNotificationHistory(NotificationHistoryBase): + client: ClassVar[NHNCloudSMSClient] = nhn_cloud_sms_client + template_class: ClassVar[type[NHNCloudSMSNotificationTemplate]] = NHNCloudSMSNotificationTemplate + sent_to_class: ClassVar[type[NHNCloudSMSNotificationHistorySentTo]] = NHNCloudSMSNotificationHistorySentTo + + template = models.ForeignKey( + NHNCloudSMSNotificationTemplate, + on_delete=models.PROTECT, + related_name="histories", + null=True, + blank=True, + ) + + objects: NHNCloudSMSNotificationHistoryQuerySet = ( + NHNCloudSMSNotificationHistoryQuerySet.as_manager() # type: ignore[misc, assignment] + ) diff --git a/app/notification/templates/email_preview.html b/app/notification/templates/email_preview.html new file mode 100644 index 0000000..4a48b2f --- /dev/null +++ b/app/notification/templates/email_preview.html @@ -0,0 +1,165 @@ + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/app/notification/templates/nhn_cloud_kakao_alimtalk_preview.html b/app/notification/templates/nhn_cloud_kakao_alimtalk_preview.html new file mode 100644 index 0000000..77f9350 --- /dev/null +++ b/app/notification/templates/nhn_cloud_kakao_alimtalk_preview.html @@ -0,0 +1,161 @@ + + + + + + + + + + + +
+
+
+ + + + 2024년 12월 8일 월요일 + +
+
+
+ + + +
+

PyConKR

+
+
+ 알림톡 도착 +
+
+ {{ templateContent }} +
+ {% for button in buttons %} + + {{ button.name }} + + {% endfor %} +
+
+
+
+ 오후 3:30 +
+
+ + + diff --git a/app/notification/templates/nhn_cloud_sms_preview.html b/app/notification/templates/nhn_cloud_sms_preview.html new file mode 100644 index 0000000..38f75f6 --- /dev/null +++ b/app/notification/templates/nhn_cloud_sms_preview.html @@ -0,0 +1,118 @@ + + + + + + + + + + + +
+
+
+ + + +
+
PyConKR
+
+
+
+ {% if title %} +

{{ title }}

+ {% endif %} + {{ body }} +
오후 3:30
+
+
+
+ + + diff --git a/app/notification/test/__init__.py b/app/notification/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/notification/test/history_send_test.py b/app/notification/test/history_send_test.py new file mode 100644 index 0000000..b80c710 --- /dev/null +++ b/app/notification/test/history_send_test.py @@ -0,0 +1,224 @@ +import logging +from unittest.mock import patch + +import pytest +from notification.models import EmailNotificationHistory, EmailNotificationHistorySentTo, EmailNotificationTemplate +from notification.models.base import NotificationStatus +from user.models import UserExt + + +@pytest.fixture +def system_user(db): + return UserExt.get_system_user() + + +@pytest.fixture +def email_template(system_user): + return EmailNotificationTemplate.objects.create( + code="c", + title="t", + sent_from="from@example.com", + data='{"title":"hi","from_":"f","send_to":"r","body":"b"}', + created_by=system_user, + updated_by=system_user, + ) + + +def _create_history(template, recipient="to@example.com", context=None): + return EmailNotificationHistory.objects.create_for_recipients( + template=template, + recipients=[{"recipient": recipient, "context": context or {}}], + ) + + +@pytest.mark.django_db +def test_sent_to_initial_status_is_created(email_template): + history = _create_history(email_template) + sent_to = history.sent_to_list.get() + assert sent_to.status == NotificationStatus.CREATED + + +@pytest.mark.django_db +def test_sent_to_success_transitions_to_sent(email_template): + history = _create_history(email_template) + sent_to = history.sent_to_list.get() + with patch.object(EmailNotificationHistory, "client") as mock_client: + sent_to.send() + mock_client.send_message.assert_called_once() + sent_to.refresh_from_db() + assert sent_to.status == NotificationStatus.SENT + + +@pytest.mark.django_db +def test_sent_to_failure_transitions_to_failed_and_propagates(email_template): + history = _create_history(email_template) + sent_to = history.sent_to_list.get() + with patch.object(EmailNotificationHistory, "client") as mock_client: + mock_client.send_message.side_effect = RuntimeError("boom") + with pytest.raises(RuntimeError, match="boom"): + sent_to.send() + sent_to.refresh_from_db() + assert sent_to.status == NotificationStatus.FAILED + + +@pytest.mark.django_db +def test_sent_to_failure_logs_to_slack_logger(email_template, caplog): + history = _create_history(email_template, recipient="bad@example.com") + sent_to = history.sent_to_list.get() + with patch.object(EmailNotificationHistory, "client") as mock_client: + mock_client.send_message.side_effect = RuntimeError("api down") + with caplog.at_level(logging.ERROR, logger="slack_logger"): + with pytest.raises(RuntimeError): + sent_to.send() + records = [r for r in caplog.records if r.name == "slack_logger"] + assert len(records) == 1 + assert records[0].levelno == logging.ERROR + assert records[0].exc_info is not None + assert "recipient=bad@example.com" in records[0].getMessage() + + +@pytest.mark.django_db +def test_history_send_parameters_uses_rendered_payload(system_user): + tpl = EmailNotificationTemplate.objects.create( + code="render", + title="t", + sent_from="a@b.c", + data='{"title":"안녕 {{ name }}","from_":"f","send_to":"r","body":"b"}', + created_by=system_user, + updated_by=system_user, + ) + history = _create_history(tpl, recipient="to@example.com", context={"name": "길동"}) + sent_to = history.sent_to_list.get() + params = sent_to.build_send_parameters() + assert params["payload"]["title"] == "안녕 길동" + assert params["send_to"] == "to@example.com" + assert params["sent_from"] == "a@b.c" + assert params["template_code"] == "render" + + +@pytest.mark.django_db +def test_history_template_code_property_returns_template_code(email_template): + history = _create_history(email_template) + assert history.template_code == email_template.code + + +@pytest.mark.django_db +def test_send_fails_fast_when_context_missing_template_variables(system_user): + # 발송 시 sent_to.context에 누락된 템플릿 변수가 있으면 즉시 실패해야 함. + tpl = EmailNotificationTemplate.objects.create( + code="missing-var", + title="t", + sent_from="a@b.c", + data='{"title":"안녕 {{ name }}님","from_":"f","send_to":"r","body":"{{ message }}"}', + created_by=system_user, + updated_by=system_user, + ) + history = _create_history(tpl, context={"name": "길동"}) + sent_to = history.sent_to_list.get() + with pytest.raises(ValueError, match="message"): + sent_to.send() + sent_to.refresh_from_db() + assert sent_to.status == NotificationStatus.FAILED + + +@pytest.mark.django_db +def test_history_send_iterates_all_sent_to_and_swallows_individual_failures(system_user, email_template): + history = EmailNotificationHistory.objects.create_for_recipients( + template=email_template, + recipients=[ + {"recipient": "ok1@example.com", "context": {}}, + {"recipient": "fail@example.com", "context": {}}, + {"recipient": "ok2@example.com", "context": {}}, + ], + ) + with patch.object(EmailNotificationHistory, "client") as mock_client: + mock_client.send_message.side_effect = [None, RuntimeError("partial fail"), None] + history.send() # 개별 실패가 batch를 멈추지 않음 + + by_recipient = {s.recipient: s for s in history.sent_to_list.all()} + assert by_recipient["ok1@example.com"].status == NotificationStatus.SENT + assert by_recipient["fail@example.com"].status == NotificationStatus.FAILED + assert by_recipient["ok2@example.com"].status == NotificationStatus.SENT + + +@pytest.mark.django_db +def test_sent_to_status_summary_counts_by_status(email_template): + history = EmailNotificationHistory.objects.create_for_recipients( + template=email_template, + recipients=[{"recipient": f"r{i}@example.com"} for i in range(3)], + ) + sent_to_qs = history.sent_to_list.order_by("recipient") + EmailNotificationHistorySentTo.objects.filter(id=sent_to_qs[0].id).update(status=NotificationStatus.SENT) + EmailNotificationHistorySentTo.objects.filter(id=sent_to_qs[1].id).update(status=NotificationStatus.FAILED) + # sent_to_qs[2] stays CREATED + + summary = history.sent_to_status_summary + assert summary == {"created": 1, "sending": 0, "sent": 1, "failed": 1} + + +@pytest.mark.django_db +def test_history_send_logs_unexpected_errors_outside_inner_try(email_template, caplog): + # SentTo.send() 내부 try 밖에서 발생한 예외(예: status save 실패)는 inner catch+log에 안 잡힘 → + # _send_each가 batch를 계속 진행하면서 상위에서 추가 로깅하는지 확인. + history = _create_history(email_template) + with patch.object(EmailNotificationHistorySentTo, "save", side_effect=RuntimeError("db down")): + with caplog.at_level(logging.ERROR, logger="slack_logger"): + history.send() # propagate 안 됨 + + records = [r for r in caplog.records if "Batch send unexpected" in r.getMessage()] + assert len(records) == 1 + assert records[0].exc_info is not None + + +@pytest.mark.django_db +def test_history_retry_skips_non_failed_sent_to(email_template): + # FAILED 상태가 아닌 sent_to는 재시도 대상에서 제외 — 외부 호출이 발생하지 않음. + history = _create_history(email_template) + with patch.object(EmailNotificationHistory, "client") as mock_client: + history.retry() + mock_client.send_message.assert_not_called() + + +@pytest.mark.django_db +def test_history_retry_resends_failed_sent_to(email_template): + history = _create_history(email_template) + history.sent_to_list.update(status=NotificationStatus.FAILED) + + with patch.object(EmailNotificationHistory, "client"): + history.retry() + sent_to = history.sent_to_list.get() + assert sent_to.status == NotificationStatus.SENT + + +@pytest.mark.django_db +def test_create_for_recipients_snapshots_template_data_and_sent_from(email_template): + # template snapshot — template이 이후 수정돼도 history는 그대로 유지. + history = _create_history(email_template) + assert history.template_data == email_template.data + assert history.sent_from == email_template.sent_from + + email_template.data = '{"title":"NEW","from_":"f","send_to":"r","body":"b"}' + email_template.save(update_fields=["data"]) + history.refresh_from_db() + assert history.template_data != email_template.data # snapshot은 그대로 + + +@pytest.mark.django_db +def test_create_for_recipients_templateless_uses_transient_template(system_user): + # template 없이 발송하려면 호출자가 unsaved EmailNotificationTemplate 인스턴스를 구성해 전달. + with pytest.raises(ValueError, match="template"): + EmailNotificationHistory.objects.create_for_recipients( + template=EmailNotificationTemplate(), + recipients=[{"recipient": "x@y.z"}], + ) + + history = EmailNotificationHistory.objects.create_for_recipients( + template=EmailNotificationTemplate( + data='{"title":"hi {{ name }}","from_":"f","send_to":"r","body":"b"}', + sent_from="from@example.com", + ), + recipients=[{"recipient": "x@y.z", "context": {"name": "x"}}], + ) + assert history.template_id is None + assert history.template_code == "" + assert history.sent_from == "from@example.com" diff --git a/app/notification/test/kakao_sync_test.py b/app/notification/test/kakao_sync_test.py new file mode 100644 index 0000000..754b509 --- /dev/null +++ b/app/notification/test/kakao_sync_test.py @@ -0,0 +1,198 @@ +from unittest.mock import MagicMock, patch + +import pytest +from core.models import BaseAbstractModelQuerySet +from notification.models import NHNCloudKakaoAlimTalkNotificationTemplate as Template +from user.models import UserExt + + +@pytest.fixture +def system_user(db): + return UserExt.get_system_user() + + +@pytest.fixture +def mock_nhn_client(): + """NHN Cloud client 싱글톤을 mock으로 교체.""" + mock = MagicMock() + mock.get_sender_list.return_value = {"senders": [{"senderKey": "S1"}]} + with patch("notification.models.nhn_cloud_kakao_alimtalk.nhn_cloud_kakao_alimtalk_client", mock): + yield mock + + +# ---- sync_with_nhn_cloud() -------------------------------------------------- + + +@pytest.mark.django_db +def test_sync_creates_new_external_templates(mock_nhn_client): + mock_nhn_client.list_templates.return_value = { + "templateListResponse": { + "templates": [ + { + "templateCode": "T1", + "templateName": "Hi", + "senderKey": "S1", + "templateContent": "안녕 #{name}", + "status": "TSC03", + }, + ] + } + } + + Template.objects.sync_with_nhn_cloud() + + assert Template.objects.filter_active().count() == 1 + row = Template.objects.get(code="T1") + assert row.title == "Hi" + assert row.sent_from == "S1" + + +@pytest.mark.django_db +def test_sync_updates_changed_templates(system_user, mock_nhn_client): + # Given: 기존 template + existing = Template( + code="X", + title="OLD", + sent_from="S1", + description="", + data='{"templateCode":"X","templateName":"OLD","senderKey":"S1","templateContent":"old","status":"TSC03"}', + created_by=system_user, + updated_by=system_user, + ) + # 차단을 우회해서 시드. (sync 외부에서 직접 만드는 정상 경로는 없음) + BaseAbstractModelQuerySet(model=Template).bulk_create([existing]) + + mock_nhn_client.list_templates.return_value = { + "templateListResponse": { + "templates": [ + { + "templateCode": "X", + "templateName": "NEW", + "senderKey": "S1", + "templateContent": "changed", + "status": "TSC03", + }, + ] + } + } + + Template.objects.sync_with_nhn_cloud() + + row = Template.objects.get(code="X") + assert row.title == "NEW" + assert "NEW" in row.data + + +@pytest.mark.django_db +def test_sync_soft_deletes_missing_templates(system_user, mock_nhn_client): + BaseAbstractModelQuerySet(model=Template).bulk_create( + [ + Template( + code="GONE", + title="g", + sent_from="S1", + description="", + data="{}", + created_by=system_user, + updated_by=system_user, + ), + ] + ) + + # NHN Cloud 응답에서 사라짐 → soft delete 대상 + mock_nhn_client.list_templates.return_value = {"templateListResponse": {"templates": []}} + Template.objects.sync_with_nhn_cloud() + + assert Template.objects.filter_active().count() == 0 + assert Template.objects.filter(code="GONE", deleted_at__isnull=False).exists() + + +@pytest.mark.django_db +def test_sync_ignores_unapproved_templates(mock_nhn_client): + # TSC02(검수 중) 등 비승인 상태는 무시 + mock_nhn_client.list_templates.return_value = { + "templateListResponse": { + "templates": [ + { + "templateCode": "approved", + "templateName": "A", + "senderKey": "S1", + "templateContent": "x", + "status": "TSC03", + }, + { + "templateCode": "pending", + "templateName": "P", + "senderKey": "S1", + "templateContent": "y", + "status": "TSC02", + }, + ] + } + } + + Template.objects.sync_with_nhn_cloud() + + codes = set(Template.objects.filter_active().values_list("code", flat=True)) + assert codes == {"approved"} + + +# ---- 로컬 CUD 차단 ---------------------------------------------------------- +# Kakao 템플릿은 NHN Cloud Console에서 관리하므로 로컬 CUD가 차단되어야 함. + + +@pytest.mark.django_db +def test_kakao_objects_create_blocked(): + with pytest.raises(NotImplementedError, match="NHN Cloud Console"): + Template.objects.create(code="x", title="y", data="{}") + + +@pytest.mark.django_db +def test_kakao_objects_bulk_create_blocked(): + with pytest.raises(NotImplementedError, match="NHN Cloud Console"): + Template.objects.bulk_create([Template(code="x", title="y", data="{}")]) + + +@pytest.mark.django_db +def test_kakao_objects_update_blocked(): + with pytest.raises(NotImplementedError, match="NHN Cloud Console"): + Template.objects.update(title="nope") + + +@pytest.mark.django_db +def test_kakao_objects_delete_blocked(): + with pytest.raises(NotImplementedError, match="NHN Cloud Console"): + Template.objects.all().delete() + + +@pytest.mark.django_db +def test_kakao_objects_get_or_create_blocked(): + with pytest.raises(NotImplementedError, match="NHN Cloud Console"): + Template.objects.get_or_create(code="x", defaults={"title": "y", "data": "{}"}) + + +@pytest.mark.django_db +def test_kakao_instance_save_blocked(): + with pytest.raises(NotImplementedError, match="NHN Cloud Console"): + Template(code="m", title="n", data="{}").save() + + +@pytest.mark.django_db +def test_kakao_instance_delete_blocked(system_user, mock_nhn_client): + mock_nhn_client.list_templates.return_value = { + "templateListResponse": { + "templates": [ + { + "templateCode": "T1", + "templateName": "Hi", + "senderKey": "S1", + "templateContent": "x", + "status": "TSC03", + }, + ] + } + } + Template.objects.sync_with_nhn_cloud() + row = Template.objects.first() + with pytest.raises(NotImplementedError, match="NHN Cloud Console"): + row.delete() diff --git a/app/notification/test/sms_test.py b/app/notification/test/sms_test.py new file mode 100644 index 0000000..37c7cdc --- /dev/null +++ b/app/notification/test/sms_test.py @@ -0,0 +1,155 @@ +from unittest.mock import patch + +import pytest +from notification.models import NHNCloudSMSNotificationHistory, NHNCloudSMSNotificationTemplate +from notification.models.base import NotificationStatus, UnhandledVariableHandling +from user.models import UserExt + + +@pytest.fixture +def system_user(db): + return UserExt.get_system_user() + + +# 인메모리 인스턴스 — 순수 render/preview 검증은 DB 영속화가 필요하지 않음. +@pytest.fixture +def sms_template_in_memory(): + return NHNCloudSMSNotificationTemplate( + code="welcome-sms", + title="Welcome SMS", + sent_from="0212345678", + data='{"body":"안녕하세요 {{ name }}님"}', + ) + + +@pytest.fixture +def mms_template_in_memory(): + return NHNCloudSMSNotificationTemplate( + code="welcome-mms", + title="Welcome MMS", + sent_from="0212345678", + data='{"title":"공지 {{ event }}","body":"안녕하세요 {{ name }}님"}', + ) + + +@pytest.fixture +def sms_template_persisted(system_user): + return NHNCloudSMSNotificationTemplate.objects.create( + code="welcome-sms", + title="Welcome SMS", + sent_from="0212345678", + data='{"body":"안녕하세요 {{ name }}님"}', + created_by=system_user, + updated_by=system_user, + ) + + +@pytest.fixture +def mms_template_persisted(system_user): + return NHNCloudSMSNotificationTemplate.objects.create( + code="welcome-mms", + title="Welcome MMS", + sent_from="0212345678", + data='{"title":"공지 {{ event }}","body":"안녕하세요 {{ name }}님"}', + created_by=system_user, + updated_by=system_user, + ) + + +# ---- SentTo.render() (template.build_preview_sent_to 경유) ------------------ + + +def test_sms_short_render_returns_body_only(sms_template_in_memory): + result = sms_template_in_memory.build_preview_sent_to({"name": "길동"}).render() + assert result == {"body": "안녕하세요 길동님"} + + +def test_sms_long_mms_render_includes_title(mms_template_in_memory): + result = mms_template_in_memory.build_preview_sent_to({"event": "PyCon", "name": "길동"}).render() + assert result == {"title": "공지 PyCon", "body": "안녕하세요 길동님"} + + +def test_sms_render_does_not_raise_when_title_empty_but_body_present(): + # title이 빈 문자열이어도 body만 있으면 단문 SMS로 발송 가능 + tpl = NHNCloudSMSNotificationTemplate(data='{"title":"{{ subj }}","body":"hello"}') + result = tpl.build_preview_sent_to({}).render(UnhandledVariableHandling.REMOVE) + assert result["body"] == "hello" + assert result["title"] == "" + + +# ---- 미리보기 HTML ---------------------------------------------------------- + + +def test_sms_preview_short_renders_body(sms_template_in_memory): + html = sms_template_in_memory.build_preview_sent_to({"name": "길동"}).render_as_html() + assert html.strip().startswith("