From 84d7ef17b4f9f9fc288eef26530d3cdad948e2b8 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sun, 26 Apr 2026 21:04:00 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20notification=20app=20=EB=B0=8F=20em?= =?UTF-8?q?ail/kakao/SMS=20=EC=95=8C=EB=A6=BC=20=EC=A7=80=EC=9B=90=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/email_backends.py | 86 ++++ app/core/external_apis/__interface__.py | 14 + .../external_apis/nhn_cloud_kakao_alimtalk.py | 51 +++ app/core/external_apis/nhn_cloud_sms.py | 60 +++ app/core/external_apis/smtp_email.py | 35 ++ app/core/models.py | 4 +- app/core/settings.py | 31 +- app/core/test/__init__.py | 0 app/core/test/email_backends_test.py | 142 +++++++ app/core/test/models_test.py | 72 ++++ app/core/test/nhn_cloud_sms_test.py | 103 +++++ app/notification/__init__.py | 0 app/notification/apps.py | 5 + app/notification/migrations/0001_initial.py | 372 ++++++++++++++++++ app/notification/models/__init__.py | 17 + app/notification/models/base.py | 133 +++++++ app/notification/models/email.py | 48 +++ .../models/nhn_cloud_kakao_alimtalk.py | 144 +++++++ app/notification/models/nhn_cloud_sms.py | 48 +++ app/notification/templates/email_preview.html | 165 ++++++++ .../nhn_cloud_kakao_alimtalk_preview.html | 161 ++++++++ .../templates/nhn_cloud_sms_preview.html | 118 ++++++ app/notification/test/__init__.py | 0 app/notification/test/history_send_test.py | 110 ++++++ app/notification/test/kakao_sync_test.py | 198 ++++++++++ app/notification/test/sms_test.py | 147 +++++++ app/notification/test/template_test.py | 119 ++++++ 27 files changed, 2381 insertions(+), 2 deletions(-) create mode 100644 app/core/email_backends.py create mode 100644 app/core/external_apis/__interface__.py create mode 100644 app/core/external_apis/nhn_cloud_kakao_alimtalk.py create mode 100644 app/core/external_apis/nhn_cloud_sms.py create mode 100644 app/core/external_apis/smtp_email.py create mode 100644 app/core/test/__init__.py create mode 100644 app/core/test/email_backends_test.py create mode 100644 app/core/test/models_test.py create mode 100644 app/core/test/nhn_cloud_sms_test.py create mode 100644 app/notification/__init__.py create mode 100644 app/notification/apps.py create mode 100644 app/notification/migrations/0001_initial.py create mode 100644 app/notification/models/__init__.py create mode 100644 app/notification/models/base.py create mode 100644 app/notification/models/email.py create mode 100644 app/notification/models/nhn_cloud_kakao_alimtalk.py create mode 100644 app/notification/models/nhn_cloud_sms.py create mode 100644 app/notification/templates/email_preview.html create mode 100644 app/notification/templates/nhn_cloud_kakao_alimtalk_preview.html create mode 100644 app/notification/templates/nhn_cloud_sms_preview.html create mode 100644 app/notification/test/__init__.py create mode 100644 app/notification/test/history_send_test.py create mode 100644 app/notification/test/kakao_sync_test.py create mode 100644 app/notification/test/sms_test.py create mode 100644 app/notification/test/template_test.py 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..84ba835 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -19,7 +19,9 @@ def create(self, **kwargs: dict) -> models.Model: 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()})) + 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()) 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..a0d9d34 --- /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", + from_address="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/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..22b8885 --- /dev/null +++ b/app/notification/migrations/0001_initial.py @@ -0,0 +1,372 @@ +# 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()), + ("from_address", models.EmailField(blank=False, max_length=254, null=False)), + ( + "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, + ), + ), + ], + ), + 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)), + ("send_to", 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, + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=PROTECT, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "template", + models.ForeignKey( + on_delete=PROTECT, + related_name="histories", + to="notification.emailnotificationtemplate", + ), + ), + ], + 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()), + ("sender_key", models.CharField(blank=True, max_length=128, null=True)), + ( + "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, + ), + ), + ], + ), + 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)), + ("send_to", 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, + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=PROTECT, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "template", + models.ForeignKey( + on_delete=PROTECT, + related_name="histories", + to="notification.nhncloudkakaoalimtalknotificationtemplate", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddConstraint( + model_name="emailnotificationtemplate", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), fields=("code",), name="uq_email_noti_template_code" + ), + ), + migrations.AddConstraint( + model_name="emailnotificationtemplate", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("code", "title"), + name="uq_email_noti_template_code_title", + ), + ), + migrations.AddConstraint( + model_name="nhncloudkakaoalimtalknotificationtemplate", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("code",), + name="uq_nhn_cloud_kakao_alimtalk_noti_template_code", + ), + ), + migrations.AddConstraint( + model_name="nhncloudkakaoalimtalknotificationtemplate", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("code", "title"), + name="uq_nhn_cloud_kakao_alimtalk_noti_template_code_title", + ), + ), + 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()), + ("from_no", models.CharField(blank=True, max_length=13, null=True)), + ( + "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, + ), + ), + ], + ), + 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)), + ("send_to", 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, + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=PROTECT, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "template", + models.ForeignKey( + on_delete=PROTECT, + related_name="histories", + to="notification.nhncloudsmsnotificationtemplate", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.AddConstraint( + model_name="nhncloudsmsnotificationtemplate", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("code",), + name="uq_nhn_cloud_sms_noti_template_code", + ), + ), + migrations.AddConstraint( + model_name="nhncloudsmsnotificationtemplate", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("code", "title"), + name="uq_nhn_cloud_sms_noti_template_code_title", + ), + ), + ] diff --git a/app/notification/models/__init__.py b/app/notification/models/__init__.py new file mode 100644 index 0000000..a756b96 --- /dev/null +++ b/app/notification/models/__init__.py @@ -0,0 +1,17 @@ +from .base import UnhandledVariableHandling +from .email import EmailNotificationHistory, EmailNotificationTemplate +from .nhn_cloud_kakao_alimtalk import ( + NHNCloudKakaoAlimTalkNotificationHistory, + NHNCloudKakaoAlimTalkNotificationTemplate, +) +from .nhn_cloud_sms import NHNCloudSMSNotificationHistory, NHNCloudSMSNotificationTemplate + +__all__ = [ + "EmailNotificationHistory", + "EmailNotificationTemplate", + "NHNCloudKakaoAlimTalkNotificationHistory", + "NHNCloudKakaoAlimTalkNotificationTemplate", + "NHNCloudSMSNotificationHistory", + "NHNCloudSMSNotificationTemplate", + "UnhandledVariableHandling", +] diff --git a/app/notification/models/base.py b/app/notification/models/base.py new file mode 100644 index 0000000..dadd59b --- /dev/null +++ b/app/notification/models/base.py @@ -0,0 +1,133 @@ +from enum import StrEnum, auto +from json import loads as json_loads +from logging import getLogger +from typing import Any, ClassVar +from uuid import uuid4 + +from core.external_apis.__interface__ import NotificationServiceInterface, SendParameters +from core.models import BaseAbstractModel +from django.db import models +from django.template import Context, Template +from django.template.base import VariableNode +from django.template.loader import get_template + +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 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() + + class Meta: + abstract = True + + def _to_dtl(self, source: str) -> str: + return source + + @staticmethod + def _extract_root_variables(template: Template) -> set[str]: + roots: set[str] = set() + for node in template.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]: + return self._extract_root_variables(Template(self._to_dtl(self.data))) + + def render( + self, + context: dict[str, str], + undefined_variable_handling: UnhandledVariableHandling = UnhandledVariableHandling.RAISE, + ) -> dict[str, Any]: + template = Template(self._to_dtl(self.data)) + context = dict(context) + missing = self._extract_root_variables(template) - context.keys() + + if missing and undefined_variable_handling is UnhandledVariableHandling.RAISE: + raise ValueError( + f"Template '{self.code}' rendered without required context variables: {sorted(missing)}", + ) + + for key in missing: + match undefined_variable_handling: + case UnhandledVariableHandling.SHOW_AS_TEMPLATE_VAR: + context[key] = f"{self.variable_start} {key} {self.variable_end}" + case UnhandledVariableHandling.RANDOM: + context[key] = f"RandomValue-{uuid4().hex[:8]}" + case UnhandledVariableHandling.REMOVE: + context[key] = "" + + return json_loads(template.render(Context(context))) + + def render_as_html( + self, + context: dict[str, str], + undefined_variable_handling: UnhandledVariableHandling = UnhandledVariableHandling.RANDOM, + ) -> str: + rendered_context = self.render(context=context, undefined_variable_handling=undefined_variable_handling) + return get_template(self.html_template_name).render(rendered_context) + + +class NotificationHistoryBase(BaseAbstractModel): + client: ClassVar[NotificationServiceInterface] + + send_to = 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 + + @property + def template_code(self) -> str: + raise NotImplementedError("Subclasses must implement template_code") + + def build_send_parameters(self) -> SendParameters: + raise NotImplementedError("Subclasses must implement build_send_parameters") + + def send(self) -> None: + self.status = NotificationStatus.SENDING + self.save(update_fields=["status"]) + try: + self.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 send_to=%s", + self.id, + self.template_code, + self.send_to, + ) + 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..8471d1e --- /dev/null +++ b/app/notification/models/email.py @@ -0,0 +1,48 @@ +from typing import ClassVar + +from core.external_apis.__interface__ import SendParameters +from core.external_apis.smtp_email import EmailClient, email_client +from django.db import models +from notification.models.base import NotificationHistoryBase, NotificationTemplateBase + + +class EmailNotificationTemplate(NotificationTemplateBase): + html_template_name: ClassVar[str] = "email_preview.html" + + from_address = models.EmailField(null=False, blank=False) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["code"], + condition=models.Q(deleted_at__isnull=True), + name="uq_email_noti_template_code", + ), + models.UniqueConstraint( + fields=["code", "title"], + condition=models.Q(deleted_at__isnull=True), + name="uq_email_noti_template_code_title", + ), + ] + + +class EmailNotificationHistory(NotificationHistoryBase): + client: ClassVar[EmailClient] = email_client + + template = models.ForeignKey( + EmailNotificationTemplate, + on_delete=models.PROTECT, + related_name="histories", + ) + + @property + def template_code(self) -> str: + return self.template.code + + def build_send_parameters(self) -> SendParameters: + return SendParameters( + payload=self.template.render(context=self.context), + send_to=self.send_to, + template_code=self.template_code, + sent_from=self.template.from_address, + ) 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..4909b4d --- /dev/null +++ b/app/notification/models/nhn_cloud_kakao_alimtalk.py @@ -0,0 +1,144 @@ +from re import compile as re_compile +from typing import Any, ClassVar, Self + +from core.external_apis.__interface__ import SendParameters +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, 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="", + sender_key=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.sender_key != ext["senderKey"] or row.data != new_data: + row.title = ext["templateName"] + row.sender_key = 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", "sender_key", "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" + + sender_key = models.CharField(max_length=128, null=True, blank=True) + + objects: NHNCloudKakaoAlimTalkNotificationTemplateQuerySet = ( + NHNCloudKakaoAlimTalkNotificationTemplateQuerySet.as_manager() # type: ignore[misc, assignment] + ) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["code"], + condition=models.Q(deleted_at__isnull=True), + name="uq_nhn_cloud_kakao_alimtalk_noti_template_code", + ), + models.UniqueConstraint( + fields=["code", "title"], + condition=models.Q(deleted_at__isnull=True), + name="uq_nhn_cloud_kakao_alimtalk_noti_template_code_title", + ), + ] + + 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) + + def _to_dtl(self, source: str) -> str: + return _KAKAO_VAR_RE.sub(r"{{ \1 }}", source) + + +class NHNCloudKakaoAlimTalkNotificationHistory(NotificationHistoryBase): + client: ClassVar[NHNCloudKakaoAlimTalkClient] = nhn_cloud_kakao_alimtalk_client + + template = models.ForeignKey( + NHNCloudKakaoAlimTalkNotificationTemplate, + on_delete=models.PROTECT, + related_name="histories", + ) + + @property + def template_code(self) -> str: + return self.template.code + + def build_send_parameters(self) -> SendParameters: + return SendParameters( + payload=self.context, + send_to=self.send_to, + template_code=self.template_code, + sent_from=self.template.sender_key, + ) diff --git a/app/notification/models/nhn_cloud_sms.py b/app/notification/models/nhn_cloud_sms.py new file mode 100644 index 0000000..e052790 --- /dev/null +++ b/app/notification/models/nhn_cloud_sms.py @@ -0,0 +1,48 @@ +from typing import ClassVar + +from core.external_apis.__interface__ import SendParameters +from core.external_apis.nhn_cloud_sms import NHNCloudSMSClient, nhn_cloud_sms_client +from django.db import models +from notification.models.base import NotificationHistoryBase, NotificationTemplateBase + + +class NHNCloudSMSNotificationTemplate(NotificationTemplateBase): + html_template_name: ClassVar[str] = "nhn_cloud_sms_preview.html" + + from_no = models.CharField(max_length=13, null=True, blank=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["code"], + condition=models.Q(deleted_at__isnull=True), + name="uq_nhn_cloud_sms_noti_template_code", + ), + models.UniqueConstraint( + fields=["code", "title"], + condition=models.Q(deleted_at__isnull=True), + name="uq_nhn_cloud_sms_noti_template_code_title", + ), + ] + + +class NHNCloudSMSNotificationHistory(NotificationHistoryBase): + client: ClassVar[NHNCloudSMSClient] = nhn_cloud_sms_client + + template = models.ForeignKey( + NHNCloudSMSNotificationTemplate, + on_delete=models.PROTECT, + related_name="histories", + ) + + @property + def template_code(self) -> str: + return self.template.code + + def build_send_parameters(self) -> SendParameters: + return SendParameters( + payload=self.template.render(context=self.context), + send_to=self.send_to, + template_code=self.template_code, + sent_from=self.template.from_no, + ) 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..8db6535 --- /dev/null +++ b/app/notification/test/history_send_test.py @@ -0,0 +1,110 @@ +import logging +from unittest.mock import patch + +import pytest +from notification.models import EmailNotificationHistory, 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", + from_address="from@example.com", + data='{"title":"hi","from_":"f","send_to":"r","body":"b"}', + created_by=system_user, + updated_by=system_user, + ) + + +@pytest.mark.django_db +def test_history_initial_status_is_created(email_template): + history = email_template.histories.create(send_to="to@example.com", context={}) + assert history.status == NotificationStatus.CREATED + + +@pytest.mark.django_db +def test_history_success_transitions_to_sent(email_template): + history = email_template.histories.create(send_to="to@example.com", context={}) + with patch.object(EmailNotificationHistory, "client") as mock_client: + history.send() + mock_client.send_message.assert_called_once() + history.refresh_from_db() + assert history.status == NotificationStatus.SENT + + +@pytest.mark.django_db +def test_history_failure_transitions_to_failed_and_propagates(email_template): + history = email_template.histories.create(send_to="to@example.com", context={}) + with patch.object(EmailNotificationHistory, "client") as mock_client: + mock_client.send_message.side_effect = RuntimeError("boom") + with pytest.raises(RuntimeError, match="boom"): + history.send() + history.refresh_from_db() + assert history.status == NotificationStatus.FAILED + + +@pytest.mark.django_db +def test_history_failure_logs_to_slack_logger(email_template, caplog): + history = email_template.histories.create(send_to="bad@example.com", context={}) + 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): + history.send() + # slack_logger에 ERROR 레벨로 기록되고, exc_info가 첨부되어야 함 + 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 "send_to=bad@example.com" in records[0].getMessage() + + +@pytest.mark.django_db +def test_history_send_parameters_uses_rendered_payload(system_user): + # build_send_parameters가 template.render(context)를 통과시키는지 확인 + tpl = EmailNotificationTemplate.objects.create( + code="render", + title="t", + from_address="a@b.c", + data='{"title":"안녕 {{ name }}","from_":"f","send_to":"r","body":"b"}', + created_by=system_user, + updated_by=system_user, + ) + history = tpl.histories.create(send_to="to@example.com", context={"name": "길동"}) + params = history.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 = email_template.histories.create(send_to="to@example.com", context={}) + assert history.template_code == email_template.code + + +@pytest.mark.django_db +def test_history_send_fails_fast_when_context_missing_template_variables(system_user): + # 발송 시 context에 누락된 템플릿 변수가 있으면 RANDOM 텍스트로 채워 보내는 게 아니라 즉시 실패해야 함. + tpl = EmailNotificationTemplate.objects.create( + code="missing-var", + title="t", + from_address="a@b.c", + data='{"title":"안녕 {{ name }}님","from_":"f","send_to":"r","body":"{{ message }}"}', + created_by=system_user, + updated_by=system_user, + ) + history = tpl.histories.create(send_to="to@example.com", context={"name": "길동"}) + with pytest.raises(ValueError, match="message"): + history.send() + history.refresh_from_db() + assert history.status == NotificationStatus.FAILED diff --git a/app/notification/test/kakao_sync_test.py b/app/notification/test/kakao_sync_test.py new file mode 100644 index 0000000..50d599d --- /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.sender_key == "S1" + + +@pytest.mark.django_db +def test_sync_updates_changed_templates(system_user, mock_nhn_client): + # Given: 기존 template + existing = Template( + code="X", + title="OLD", + sender_key="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", + sender_key="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..79750b4 --- /dev/null +++ b/app/notification/test/sms_test.py @@ -0,0 +1,147 @@ +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", + from_no="0212345678", + data='{"body":"안녕하세요 {{ name }}님"}', + ) + + +@pytest.fixture +def mms_template_in_memory(): + return NHNCloudSMSNotificationTemplate( + code="welcome-mms", + title="Welcome MMS", + from_no="0212345678", + data='{"title":"공지 {{ event }}","body":"안녕하세요 {{ name }}님"}', + ) + + +@pytest.fixture +def sms_template_persisted(system_user): + return NHNCloudSMSNotificationTemplate.objects.create( + code="welcome-sms", + title="Welcome SMS", + from_no="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", + from_no="0212345678", + data='{"title":"공지 {{ event }}","body":"안녕하세요 {{ name }}님"}', + created_by=system_user, + updated_by=system_user, + ) + + +# ---- 템플릿 render() -------------------------------------------------------- + + +def test_sms_short_render_returns_body_only(sms_template_in_memory): + result = sms_template_in_memory.render({"name": "길동"}) + assert result == {"body": "안녕하세요 길동님"} + + +def test_sms_long_mms_render_includes_title(mms_template_in_memory): + result = mms_template_in_memory.render({"event": "PyCon", "name": "길동"}) + 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.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.render_as_html({"name": "길동"}) + assert html.strip().startswith(" Date: Sun, 26 Apr 2026 22:25:20 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20Google=20OAuth2=EA=B0=80=20=EB=8F=99?= =?UTF-8?q?=EC=9E=91=ED=95=98=EC=A7=80=20=EB=AA=BB=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/util/google_api.py | 7 ++++--- app/external_api/google_oauth2/views.py | 8 ++++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/app/core/util/google_api.py b/app/core/util/google_api.py index 150e8eb..1a6288e 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]: + url, _ = 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 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..b63c839 100644 --- a/app/external_api/google_oauth2/views.py +++ b/app/external_api/google_oauth2/views.py @@ -45,11 +45,13 @@ 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 = create_authorization_url(flow=flow) + request.session["google_oauth2_code_verifier"] = code_verifier + 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: @@ -59,6 +61,8 @@ def redirect_google_oauth2(self, request: request.Request, *args, **kwargs) -> r 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() From 5e35bcb412bbf0dca979aa4d585e27e6b2242a44 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Mon, 27 Apr 2026 00:00:21 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20notification=20app=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20admin=20api=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin_api/filtersets/notification.py | 11 + app/admin_api/serializers/notification.py | 88 ++++++ app/admin_api/test/conftest.py | 72 +++++ app/admin_api/test/notification_test.py | 347 ++++++++++++++++++++++ app/admin_api/urls.py | 39 +++ app/admin_api/views/notification.py | 131 ++++++++ app/core/const/tag.py | 3 + app/core/models.py | 4 + app/core/openapi/schemas.py | 18 +- 9 files changed, 712 insertions(+), 1 deletion(-) create mode 100644 app/admin_api/filtersets/notification.py create mode 100644 app/admin_api/serializers/notification.py create mode 100644 app/admin_api/test/conftest.py create mode 100644 app/admin_api/test/notification_test.py create mode 100644 app/admin_api/views/notification.py 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..39c6ad4 --- /dev/null +++ b/app/admin_api/serializers/notification.py @@ -0,0 +1,88 @@ +from contextlib import suppress +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 ( + EmailNotificationTemplate, + NHNCloudKakaoAlimTalkNotificationTemplate, + NHNCloudSMSNotificationTemplate, +) +from notification.models.base import ( + NotificationHistoryBase, + NotificationStatus, + NotificationTemplateBase, + UnhandledVariableHandling, +) +from rest_framework import serializers + + +class NotificationHistoryAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer): + template = serializers.UUIDField(source="template_id", read_only=True) + template_code = serializers.CharField(read_only=True) + send_to = serializers.CharField(read_only=True) + context = serializers.JSONField(read_only=True) + status = serializers.ChoiceField(choices=NotificationStatus.choices) + + def validate_status(self, value: str) -> str: + if not self.instance: + return value + + if not (self.instance.status == NotificationStatus.SENDING and value == NotificationStatus.FAILED): + raise serializers.ValidationError( + f"상태 변경은 SENDING → FAILED만 가능해요. ({self.instance.status} → {value})" + ) + return value + + def update(self, instance: NotificationHistoryBase, validated_data: dict[str, Any]) -> NotificationHistoryBase: + instance.status = validated_data["status"] + instance.save(update_fields=["status"]) + return instance + + +class _NotiTemplateAdminSerializerBase(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer): + template_variables = serializers.SerializerMethodField() + + 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.render_as_html( + context=context, + undefined_variable_handling=UnhandledVariableHandling.RANDOM, + ) + + def create_history(self, send_to: str, context: dict[str, Any]) -> NotificationHistoryBase: + history = self.instance.histories.create(send_to=send_to, context=context) + with suppress(Exception): + history.send() + return history + + +class EmailNotificationTemplateAdminSerializer(_NotiTemplateAdminSerializerBase): + class Meta: + model = EmailNotificationTemplate + fields = COMMON_ADMIN_FIELDS + ("code", "title", "description", "data", "from_address", "template_variables") + + +class NHNCloudKakaoAlimTalkNotificationTemplateAdminSerializer(_NotiTemplateAdminSerializerBase): + class Meta: + model = NHNCloudKakaoAlimTalkNotificationTemplate + fields = COMMON_ADMIN_FIELDS + ("code", "title", "description", "data", "sender_key", "template_variables") + read_only_fields = fields # NHN Cloud Console에서 관리되므로 모든 필드 read-only. + + +class NHNCloudSMSNotificationTemplateAdminSerializer(_NotiTemplateAdminSerializerBase): + class Meta: + model = NHNCloudSMSNotificationTemplate + fields = COMMON_ADMIN_FIELDS + ("code", "title", "description", "data", "from_no", "template_variables") + + +class NotificationTemplateRenderRequestAdminSerializer(serializers.Serializer): + context = serializers.JSONField(required=False, default=dict) + + +class NotificationHistoryCreateRequestAdminSerializer(serializers.Serializer): + send_to = serializers.CharField(max_length=256) + 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..b053d3f --- /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="환영합니다", + from_address="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 환영", + from_no="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="알림톡 환영", + sender_key="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..677b3e0 --- /dev/null +++ b/app/admin_api/test/notification_test.py @@ -0,0 +1,347 @@ +import http +from unittest.mock import patch + +import pytest +from django.urls import reverse +from notification.models import EmailNotificationTemplate +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": "신규", + "from_address": "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", from_address="a@x.com", data="{}", created_by=superuser, updated_by=superuser + ) + EmailNotificationTemplate.objects.create( + code="goodbye", title="B", from_address="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="환영합니다", from_address="a@x.com", data="{}", created_by=superuser, updated_by=superuser + ) + EmailNotificationTemplate.objects.create( + code="t2", title="안녕히가세요", from_address="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( + request=NotificationHistoryCreateRequestAdminSerializer, + responses={HTTP_201_CREATED: NotificationHistoryAdminSerializer}, + ) + @action(detail=True, methods=["post"], url_path="history") + def create_history(self, request: Request, *args: tuple, **kwargs: dict) -> Response: + request_serializer = NotificationHistoryCreateRequestAdminSerializer(data=request.data) + request_serializer.is_valid(raise_exception=True) + + template_serializer = self.get_serializer(instance=self.get_object()) + history = template_serializer.create_history(**request_serializer.validated_data) + return Response(data=NotificationHistoryAdminSerializer(instance=history).data, status=HTTP_201_CREATED) + + +class _NotiHistoryAdminViewSetBase(ListModelMixin, RetrieveModelMixin, UpdateModelMixin, JsonSchemaViewSet): + http_method_names = ["get", "patch", "post"] + permission_classes = [IsSuperUser] + filterset_class = NotificationHistoryAdminFilterSet + serializer_class = NotificationHistoryAdminSerializer + + @extend_schema(responses={HTTP_200_OK: NotificationHistoryAdminSerializer}) + @action(detail=True, methods=["post"], url_path="retry") + def retry(self, *args: tuple, **kwargs: dict) -> Response: + history: NotificationHistoryBase = self.get_object() + if history.status != NotificationStatus.FAILED: + raise ValidationError(f"재시도는 FAILED 상태에서만 가능합니다. (현재: {history.status})") + + with suppress(Exception): + history.send() + + return Response(data=self.get_serializer(history).data) + + +# ---- Template ----------------------------------------------------------- + + +@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 ---------------------------------------------------------------- + + +@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_NOTI_EMAIL]) for m in HISTORY_METHODS}) +class EmailNotificationHistoryAdminViewSet(_NotiHistoryAdminViewSetBase): + queryset = EmailNotificationHistory.objects.filter_active().select_related_with_user("template") + + +@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_NOTI_KAKAO_ALIMTALK]) for m in HISTORY_METHODS}) +class NHNCloudKakaoAlimTalkNotificationHistoryAdminViewSet(_NotiHistoryAdminViewSetBase): + queryset = NHNCloudKakaoAlimTalkNotificationHistory.objects.filter_active().select_related_with_user("template") + + +@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_NOTI_SMS]) for m in HISTORY_METHODS}) +class NHNCloudSMSNotificationHistoryAdminViewSet(_NotiHistoryAdminViewSetBase): + queryset = NHNCloudSMSNotificationHistory.objects.filter_active().select_related_with_user("template") 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/models.py b/app/core/models.py index 84ba835..73c1add 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -32,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)} From a55dab8836ee8ae9af7e73bb112fb8b415555ec2 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Fri, 1 May 2026 22:02:21 +0900 Subject: [PATCH 4/5] =?UTF-8?q?refactor:=20=EC=97=AC=EB=9F=AC=20=EC=82=AC?= =?UTF-8?q?=EB=9E=8C=ED=95=9C=ED=85=8C=20=ED=95=9C=EB=B2=88=EC=97=90=20?= =?UTF-8?q?=EB=B3=B4=EB=82=BC=20=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- app/admin_api/serializers/notification.py | 175 +++++++--- app/admin_api/test/conftest.py | 6 +- app/admin_api/test/notification_test.py | 211 +++++++----- app/admin_api/views/notification.py | 88 +++-- app/core/test/models_test.py | 2 +- app/notification/migrations/0001_initial.py | 309 ++++++++++++++---- app/notification/models/__init__.py | 15 +- app/notification/models/base.py | 246 +++++++++++--- app/notification/models/email.py | 48 ++- .../models/nhn_cloud_kakao_alimtalk.py | 75 +++-- app/notification/models/nhn_cloud_sms.py | 48 ++- app/notification/test/history_send_test.py | 176 ++++++++-- app/notification/test/kakao_sync_test.py | 6 +- app/notification/test/sms_test.py | 66 ++-- app/notification/test/template_test.py | 82 ++++- 15 files changed, 1094 insertions(+), 459 deletions(-) diff --git a/app/admin_api/serializers/notification.py b/app/admin_api/serializers/notification.py index 39c6ad4..6996e58 100644 --- a/app/admin_api/serializers/notification.py +++ b/app/admin_api/serializers/notification.py @@ -1,88 +1,171 @@ -from contextlib import suppress 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, - NotificationStatus, - NotificationTemplateBase, - UnhandledVariableHandling, -) +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) -class NotificationHistoryAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer): - template = serializers.UUIDField(source="template_id", read_only=True) template_code = serializers.CharField(read_only=True) - send_to = serializers.CharField(read_only=True) - context = serializers.JSONField(read_only=True) - status = serializers.ChoiceField(choices=NotificationStatus.choices) + 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할 수 있습니다.") - def validate_status(self, value: str) -> str: - if not self.instance: - return value + self.instance.retry() + self.instance.refresh_from_db() - if not (self.instance.status == NotificationStatus.SENDING and value == NotificationStatus.FAILED): - raise serializers.ValidationError( - f"상태 변경은 SENDING → FAILED만 가능해요. ({self.instance.status} → {value})" - ) - return value - def update(self, instance: NotificationHistoryBase, validated_data: dict[str, Any]) -> NotificationHistoryBase: - instance.status = validated_data["status"] - instance.save(update_fields=["status"]) - return instance +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.render_as_html( - context=context, - undefined_variable_handling=UnhandledVariableHandling.RANDOM, - ) - - def create_history(self, send_to: str, context: dict[str, Any]) -> NotificationHistoryBase: - history = self.instance.histories.create(send_to=send_to, context=context) - with suppress(Exception): - history.send() - return history + return self.instance.build_preview_sent_to(context).render_as_html(undef_var=UnhandledVariableHandling.RANDOM) class EmailNotificationTemplateAdminSerializer(_NotiTemplateAdminSerializerBase): - class Meta: + sent_from = serializers.EmailField(max_length=254) + + class Meta(_NotiTemplateAdminSerializerBase.Meta): model = EmailNotificationTemplate - fields = COMMON_ADMIN_FIELDS + ("code", "title", "description", "data", "from_address", "template_variables") class NHNCloudKakaoAlimTalkNotificationTemplateAdminSerializer(_NotiTemplateAdminSerializerBase): - class Meta: + class Meta(_NotiTemplateAdminSerializerBase.Meta): model = NHNCloudKakaoAlimTalkNotificationTemplate - fields = COMMON_ADMIN_FIELDS + ("code", "title", "description", "data", "sender_key", "template_variables") - read_only_fields = fields # NHN Cloud Console에서 관리되므로 모든 필드 read-only. + read_only_fields = ( + _NotiTemplateAdminSerializerBase.Meta.fields + ) # NHN Cloud Console에서 관리되므로 모든 필드 read-only. class NHNCloudSMSNotificationTemplateAdminSerializer(_NotiTemplateAdminSerializerBase): - class Meta: + sent_from = serializers.CharField(max_length=13) + + class Meta(_NotiTemplateAdminSerializerBase.Meta): model = NHNCloudSMSNotificationTemplate - fields = COMMON_ADMIN_FIELDS + ("code", "title", "description", "data", "from_no", "template_variables") class NotificationTemplateRenderRequestAdminSerializer(serializers.Serializer): context = serializers.JSONField(required=False, default=dict) - - -class NotificationHistoryCreateRequestAdminSerializer(serializers.Serializer): - send_to = serializers.CharField(max_length=256) - context = serializers.JSONField(required=False, default=dict) diff --git a/app/admin_api/test/conftest.py b/app/admin_api/test/conftest.py index b053d3f..59f1211 100644 --- a/app/admin_api/test/conftest.py +++ b/app/admin_api/test/conftest.py @@ -38,7 +38,7 @@ def email_template(superuser) -> EmailNotificationTemplate: return EmailNotificationTemplate.objects.create( code="welcome", title="환영합니다", - from_address="from@example.com", + sent_from="from@example.com", data='{"title":"Hi {{ name }}","from_":"f","send_to":"{{ recipient }}","body":"Hello {{ name }}"}', created_by=superuser, updated_by=superuser, @@ -50,7 +50,7 @@ def sms_template(superuser) -> NHNCloudSMSNotificationTemplate: return NHNCloudSMSNotificationTemplate.objects.create( code="sms-welcome", title="SMS 환영", - from_no="0212345678", + sent_from="0212345678", data='{"body":"안녕 {{ name }}님"}', created_by=superuser, updated_by=superuser, @@ -63,7 +63,7 @@ def kakao_template(superuser) -> NHNCloudKakaoAlimTalkNotificationTemplate: template = NHNCloudKakaoAlimTalkNotificationTemplate( code="kakao-welcome", title="알림톡 환영", - sender_key="S1", + sent_from="S1", data='{"templateContent":"안녕 #{name}","buttons":[]}', created_by=superuser, updated_by=superuser, diff --git a/app/admin_api/test/notification_test.py b/app/admin_api/test/notification_test.py index 677b3e0..ac9b99e 100644 --- a/app/admin_api/test/notification_test.py +++ b/app/admin_api/test/notification_test.py @@ -3,7 +3,11 @@ import pytest from django.urls import reverse -from notification.models import EmailNotificationTemplate +from notification.models import ( + EmailNotificationHistory, + EmailNotificationTemplate, + NHNCloudSMSNotificationHistory, +) from notification.models.base import NotificationStatus from rest_framework.test import APIClient @@ -42,7 +46,7 @@ def test_template_create(api_client): data={ "code": "new-tpl", "title": "신규", - "from_address": "from@example.com", + "sent_from": "from@example.com", "data": '{"title":"x","from_":"f","send_to":"r","body":"b"}', }, format="json", @@ -79,10 +83,10 @@ def test_template_destroy_soft_deletes(api_client, email_template): @pytest.mark.django_db def test_template_list_filter_by_code(api_client, superuser): EmailNotificationTemplate.objects.create( - code="welcome", title="A", from_address="a@x.com", data="{}", created_by=superuser, updated_by=superuser + code="welcome", title="A", sent_from="a@x.com", data="{}", created_by=superuser, updated_by=superuser ) EmailNotificationTemplate.objects.create( - code="goodbye", title="B", from_address="a@x.com", data="{}", created_by=superuser, updated_by=superuser + 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"}) @@ -94,10 +98,10 @@ def test_template_list_filter_by_code(api_client, superuser): @pytest.mark.django_db def test_template_list_filter_by_title(api_client, superuser): EmailNotificationTemplate.objects.create( - code="t1", title="환영합니다", from_address="a@x.com", data="{}", created_by=superuser, updated_by=superuser + code="t1", title="환영합니다", sent_from="a@x.com", data="{}", created_by=superuser, updated_by=superuser ) EmailNotificationTemplate.objects.create( - code="t2", title="안녕히가세요", from_address="a@x.com", data="{}", created_by=superuser, updated_by=superuser + 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": "환영"}) @@ -125,7 +129,6 @@ def test_render_preview_returns_html_with_text_html_content_type(api_client, ema @pytest.mark.django_db def test_render_preview_fills_missing_variables_with_random_placeholder(api_client, email_template): - # context를 비워도 missing variable에 대해 RANDOM placeholder로 채워서 항상 렌더 가능해야 함. response = api_client.post( reverse("v1:admin-notification-email-template-render-preview", kwargs={"pk": email_template.id}), data={"context": {}}, @@ -151,7 +154,6 @@ def test_render_preview_works_for_kakao_template(api_client, kakao_template): @pytest.mark.django_db def test_kakao_template_post_to_collection_is_405(api_client): - # Kakao는 ReadOnlyModelViewSet — collection POST(create) 미지원 response = api_client.post(reverse("v1:admin-notification-kakao-template-list"), data={}, format="json") assert response.status_code == http.HTTPStatus.METHOD_NOT_ALLOWED @@ -174,35 +176,99 @@ def test_kakao_template_delete_is_405(api_client, kakao_template): assert response.status_code == http.HTTPStatus.METHOD_NOT_ALLOWED -# ---- create_history (POST /template/{id}/history/) -------------------------- +# ---- History create (POST /history/) ---------------------------------------- @pytest.mark.django_db -def test_create_history_creates_row_and_marks_sent_on_success(api_client, sms_template): +def test_create_history_via_template_creates_sent_to_and_sends(api_client, sms_template): with patch("notification.models.nhn_cloud_sms.NHNCloudSMSNotificationHistory.client"): response = api_client.post( - reverse("v1:admin-notification-sms-template-create-history", kwargs={"pk": sms_template.id}), - data={"send_to": "01012345678", "context": {"name": "길동"}}, + reverse("v1:admin-notification-sms-history-list"), + data={ + "template": str(sms_template.id), + "sent_to_list": [{"recipient": "01012345678", "context": {"name": "길동"}}], + }, format="json", ) assert response.status_code == http.HTTPStatus.CREATED body = response.json() - assert body["status"] == NotificationStatus.SENT - assert body["send_to"] == "01012345678" + assert body["template_code"] == sms_template.code + assert body["sent_from"] == sms_template.sent_from + assert body["sent_to_status_summary"]["sent"] == 1 + [sent_to] = body["sent_to_list"] + assert sent_to["recipient"] == "01012345678" + assert sent_to["status"] == NotificationStatus.SENT @pytest.mark.django_db -def test_create_history_marks_failed_when_send_raises(api_client, sms_template): - # 외부 발송이 실패해도 응답은 201이고, status가 FAILED로 영속화되어 있어야 함. +def test_create_history_marks_sent_to_failed_when_send_raises(api_client, sms_template): with patch("notification.models.nhn_cloud_sms.NHNCloudSMSNotificationHistory.client") as mock_client: mock_client.send_message.side_effect = RuntimeError("external api down") response = api_client.post( - reverse("v1:admin-notification-sms-template-create-history", kwargs={"pk": sms_template.id}), - data={"send_to": "01012345678", "context": {"name": "길동"}}, + reverse("v1:admin-notification-sms-history-list"), + data={ + "template": str(sms_template.id), + "sent_to_list": [{"recipient": "01012345678", "context": {"name": "길동"}}], + }, format="json", ) assert response.status_code == http.HTTPStatus.CREATED - assert response.json()["status"] == NotificationStatus.FAILED + body = response.json() + assert body["sent_to_status_summary"]["failed"] == 1 + assert body["sent_to_list"][0]["status"] == NotificationStatus.FAILED + + +@pytest.mark.django_db +def test_create_history_with_multiple_recipients_and_per_recipient_context(api_client, sms_template): + # 한 번의 요청으로 여러 수신자에게 서로 다른 context로 발송, 결과는 같은 history로 묶여 조회됨. + with patch("notification.models.nhn_cloud_sms.NHNCloudSMSNotificationHistory.client") as mock_client: + mock_client.send_message.side_effect = [None, RuntimeError("partial fail")] + response = api_client.post( + reverse("v1:admin-notification-sms-history-list"), + data={ + "template": str(sms_template.id), + "sent_to_list": [ + {"recipient": "01000000001", "context": {"name": "A"}}, + {"recipient": "01000000002", "context": {"name": "B"}}, + ], + }, + format="json", + ) + assert response.status_code == http.HTTPStatus.CREATED + body = response.json() + assert body["sent_to_status_summary"] == {"created": 0, "sending": 0, "sent": 1, "failed": 1} + assert {s["recipient"] for s in body["sent_to_list"]} == {"01000000001", "01000000002"} + + +@pytest.mark.django_db +def test_create_history_templateless_email(api_client): + # 템플릿 없이 template_data + sent_from 직접 입력해 발송. + with patch("notification.models.email.EmailNotificationHistory.client"): + response = api_client.post( + reverse("v1:admin-notification-email-history-list"), + data={ + "template_data": '{"title":"hi {{ name }}","from_":"f","send_to":"r","body":"b"}', + "sent_from": "from@example.com", + "sent_to_list": [{"recipient": "to@example.com", "context": {"name": "길동"}}], + }, + format="json", + ) + assert response.status_code == http.HTTPStatus.CREATED + body = response.json() + assert body["template"] is None + assert body["template_code"] == "" + assert body["sent_from"] == "from@example.com" + assert body["sent_to_status_summary"]["sent"] == 1 + + +@pytest.mark.django_db +def test_create_history_kakao_requires_template(api_client): + response = api_client.post( + reverse("v1:admin-notification-kakao-history-list"), + data={"sent_to_list": [{"recipient": "01012345678"}]}, + format="json", + ) + assert response.status_code == http.HTTPStatus.BAD_REQUEST # ---- History List / Retrieve / Filter --------------------------------------- @@ -210,7 +276,10 @@ def test_create_history_marks_failed_when_send_raises(api_client, sms_template): @pytest.fixture def email_history(email_template): - return email_template.histories.create(send_to="to@example.com", context={"name": "길동", "recipient": "x"}) + return EmailNotificationHistory.objects.create_for_recipients( + template=email_template, + recipients=[{"recipient": "to@example.com", "context": {"name": "길동", "recipient": "x"}}], + ) @pytest.mark.django_db @@ -224,10 +293,12 @@ def test_history_list_returns_rows(api_client, email_history): @pytest.mark.django_db def test_history_list_filter_by_template(api_client, email_template, superuser): other_template = EmailNotificationTemplate.objects.create( - code="other", title="X", from_address="a@x.com", data="{}", created_by=superuser, updated_by=superuser + code="other", title="X", sent_from="a@x.com", data="{}", created_by=superuser, updated_by=superuser ) - matching = email_template.histories.create(send_to="t@x", context={}) - other_template.histories.create(send_to="t@x", context={}) + matching = EmailNotificationHistory.objects.create_for_recipients( + template=email_template, recipients=[{"recipient": "t@x"}] + ) + EmailNotificationHistory.objects.create_for_recipients(template=other_template, recipients=[{"recipient": "t@x"}]) response = api_client.get(reverse("v1:admin-notification-email-history-list"), {"template": str(email_template.id)}) assert response.status_code == http.HTTPStatus.OK @@ -238,11 +309,14 @@ def test_history_list_filter_by_template(api_client, email_template, superuser): @pytest.mark.django_db def test_history_list_filter_by_created_by_username(api_client, email_template, superuser): # API 경로로 history를 만들어야 BaseAbstractModelQuerySet.create()의 get_current_user()가 - # 인증된 superuser를 created_by로 잡음 (fixture에서 직접 .create()하면 thread_local이 비어있음). + # 인증된 superuser를 created_by로 잡음. with patch("notification.models.email.EmailNotificationHistory.client"): api_client.post( - reverse("v1:admin-notification-email-template-create-history", kwargs={"pk": email_template.id}), - data={"send_to": "to@example.com", "context": {"name": "x", "recipient": "y"}}, + reverse("v1:admin-notification-email-history-list"), + data={ + "template": str(email_template.id), + "sent_to_list": [{"recipient": "to@example.com", "context": {"name": "x", "recipient": "y"}}], + }, format="json", ) @@ -253,68 +327,34 @@ def test_history_list_filter_by_created_by_username(api_client, email_template, assert len(response.json()) == 1 -# ---- History PATCH (SENDING → FAILED 만 허용) ------------------------------- - - -@pytest.mark.django_db -def test_history_patch_allows_sending_to_failed(api_client, email_history): - email_history.status = NotificationStatus.SENDING - email_history.save(update_fields=["status"]) - - response = api_client.patch( - reverse("v1:admin-notification-email-history-detail", kwargs={"pk": email_history.id}), - data={"status": NotificationStatus.FAILED}, - format="json", - ) - assert response.status_code == http.HTTPStatus.OK - email_history.refresh_from_db() - assert email_history.status == NotificationStatus.FAILED - - -@pytest.mark.django_db -def test_history_patch_rejects_other_transitions(api_client, email_history): - # CREATED → FAILED 같은 임의 전이는 거부되어야 함. - response = api_client.patch( - reverse("v1:admin-notification-email-history-detail", kwargs={"pk": email_history.id}), - data={"status": NotificationStatus.FAILED}, - format="json", - ) - assert response.status_code == http.HTTPStatus.BAD_REQUEST - - -@pytest.mark.django_db -def test_history_patch_rejects_sending_to_sent(api_client, email_history): - email_history.status = NotificationStatus.SENDING - email_history.save(update_fields=["status"]) - response = api_client.patch( - reverse("v1:admin-notification-email-history-detail", kwargs={"pk": email_history.id}), - data={"status": NotificationStatus.SENT}, - format="json", - ) - assert response.status_code == http.HTTPStatus.BAD_REQUEST - - # ---- History Retry ---------------------------------------------------------- @pytest.mark.django_db -def test_retry_succeeds_on_failed_history(api_client, email_history): - email_history.status = NotificationStatus.FAILED - email_history.save(update_fields=["status"]) +def test_retry_resends_only_failed_sent_to(api_client, email_history): + # 한 history 안에 SENT/FAILED가 섞여 있을 때 retry는 FAILED만 재시도. + extra = EmailNotificationHistory.objects.create_for_recipients( + template=email_history.template, + recipients=[{"recipient": "extra@example.com"}], + ) + # email_history의 sent_to 1개를 FAILED로, extra의 sent_to를 SENT로 설정 (격리 검증용) + email_history.sent_to_list.update(status=NotificationStatus.FAILED) + extra.sent_to_list.update(status=NotificationStatus.SENT) - with patch("notification.models.email.EmailNotificationHistory.client"): + with patch("notification.models.email.EmailNotificationHistory.client") as mock_client: response = api_client.post( reverse("v1:admin-notification-email-history-retry", kwargs={"pk": email_history.id}) ) assert response.status_code == http.HTTPStatus.OK + assert mock_client.send_message.call_count == 1 # FAILED 1개만 재시도됨 + email_history.refresh_from_db() - assert email_history.status == NotificationStatus.SENT + assert email_history.sent_to_list.get().status == NotificationStatus.SENT @pytest.mark.django_db -def test_retry_keeps_status_failed_when_send_raises(api_client, email_history): - email_history.status = NotificationStatus.FAILED - email_history.save(update_fields=["status"]) +def test_retry_keeps_sent_to_failed_when_send_raises(api_client, email_history): + email_history.sent_to_list.update(status=NotificationStatus.FAILED) with patch("notification.models.email.EmailNotificationHistory.client") as mock_client: mock_client.send_message.side_effect = RuntimeError("still down") @@ -322,15 +362,19 @@ def test_retry_keeps_status_failed_when_send_raises(api_client, email_history): reverse("v1:admin-notification-email-history-retry", kwargs={"pk": email_history.id}) ) assert response.status_code == http.HTTPStatus.OK - email_history.refresh_from_db() - assert email_history.status == NotificationStatus.FAILED + sent_to = email_history.sent_to_list.get() + assert sent_to.status == NotificationStatus.FAILED @pytest.mark.django_db -def test_retry_rejects_non_failed_history(api_client, email_history): - # 기본 상태(CREATED)는 retry 대상이 아님. - response = api_client.post(reverse("v1:admin-notification-email-history-retry", kwargs={"pk": email_history.id})) - assert response.status_code == http.HTTPStatus.BAD_REQUEST +def test_retry_noop_when_no_failed_sent_to(api_client, email_history): + # 기본 상태(CREATED)는 retry 대상이 아님 → 200 + 외부 호출 없음. + with patch("notification.models.email.EmailNotificationHistory.client") as mock_client: + response = api_client.post( + reverse("v1:admin-notification-email-history-retry", kwargs={"pk": email_history.id}) + ) + assert response.status_code == http.HTTPStatus.OK + mock_client.send_message.assert_not_called() # ---- 채널 간 격리 ----------------------------------------------------------- @@ -338,7 +382,10 @@ def test_retry_rejects_non_failed_history(api_client, email_history): @pytest.mark.django_db def test_email_history_endpoint_does_not_return_sms_histories(api_client, sms_template): - sms_template.histories.create(send_to="01012345678", context={"name": "길동"}) + NHNCloudSMSNotificationHistory.objects.create_for_recipients( + template=sms_template, + recipients=[{"recipient": "01012345678", "context": {"name": "길동"}}], + ) response = api_client.get(reverse("v1:admin-notification-email-history-list")) assert response.status_code == http.HTTPStatus.OK diff --git a/app/admin_api/views/notification.py b/app/admin_api/views/notification.py index 86fa5c0..6cc7a4d 100644 --- a/app/admin_api/views/notification.py +++ b/app/admin_api/views/notification.py @@ -1,14 +1,13 @@ from __future__ import annotations -from contextlib import suppress - from admin_api.filtersets.notification import NotificationHistoryAdminFilterSet, NotificationTemplateAdminFilterSet from admin_api.serializers.notification import ( + EmailNotificationHistoryAdminSerializer, EmailNotificationTemplateAdminSerializer, + NHNCloudKakaoAlimTalkNotificationHistoryAdminSerializer, NHNCloudKakaoAlimTalkNotificationTemplateAdminSerializer, + NHNCloudSMSNotificationHistoryAdminSerializer, NHNCloudSMSNotificationTemplateAdminSerializer, - NotificationHistoryAdminSerializer, - NotificationHistoryCreateRequestAdminSerializer, NotificationTemplateRenderRequestAdminSerializer, ) from core.const.tag import OpenAPITag @@ -24,19 +23,19 @@ NHNCloudSMSNotificationHistory, NHNCloudSMSNotificationTemplate, ) -from notification.models.base import NotificationHistoryBase, NotificationStatus from rest_framework.decorators import action -from rest_framework.mixins import ListModelMixin, RetrieveModelMixin, UpdateModelMixin +from rest_framework.mixins import CreateModelMixin, ListModelMixin, RetrieveModelMixin from rest_framework.renderers import StaticHTMLRenderer from rest_framework.request import Request from rest_framework.response import Response -from rest_framework.serializers import ValidationError -from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet -TEMPLATE_READ_METHODS = ["list", "retrieve", "render_preview", "create_history"] +TEMPLATE_READ_METHODS = ["list", "retrieve", "render_preview"] TEMPLATE_CRUD_METHODS = TEMPLATE_READ_METHODS + ["create", "update", "partial_update", "destroy"] -HISTORY_METHODS = ["list", "retrieve", "partial_update", "retry"] +HISTORY_METHODS = ["list", "retrieve", "create", "retry"] + + +# ---- Template ----------------------------------------------------------- class _NotiTemplateAdminActionMixin(JsonSchemaViewSet): @@ -48,7 +47,7 @@ class _NotiTemplateAdminActionMixin(JsonSchemaViewSet): responses=build_html_responses(names=["Notification Template Render Preview"]), ) # @action 에서 serializer_class를 override하지 않음 — get_serializer()가 viewset의 template serializer를 - # 그대로 반환해야 instance 메서드(render/create_history) 사용 가능. 요청 body 스키마는 위 @extend_schema에서 명시. + # 그대로 반환해야 instance 메서드(render) 사용 가능. 요청 body 스키마는 위 @extend_schema에서 명시. @action(detail=True, methods=["post"], url_path="render", renderer_classes=[StaticHTMLRenderer]) def render_preview(self, request: Request, *args: tuple, **kwargs: dict) -> Response: request_serializer = NotificationTemplateRenderRequestAdminSerializer(data=request.data) @@ -57,41 +56,6 @@ def render_preview(self, request: Request, *args: tuple, **kwargs: dict) -> Resp template_serializer = self.get_serializer(instance=self.get_object()) return Response(data=template_serializer.render(request_serializer.validated_data["context"])) - @extend_schema( - request=NotificationHistoryCreateRequestAdminSerializer, - responses={HTTP_201_CREATED: NotificationHistoryAdminSerializer}, - ) - @action(detail=True, methods=["post"], url_path="history") - def create_history(self, request: Request, *args: tuple, **kwargs: dict) -> Response: - request_serializer = NotificationHistoryCreateRequestAdminSerializer(data=request.data) - request_serializer.is_valid(raise_exception=True) - - template_serializer = self.get_serializer(instance=self.get_object()) - history = template_serializer.create_history(**request_serializer.validated_data) - return Response(data=NotificationHistoryAdminSerializer(instance=history).data, status=HTTP_201_CREATED) - - -class _NotiHistoryAdminViewSetBase(ListModelMixin, RetrieveModelMixin, UpdateModelMixin, JsonSchemaViewSet): - http_method_names = ["get", "patch", "post"] - permission_classes = [IsSuperUser] - filterset_class = NotificationHistoryAdminFilterSet - serializer_class = NotificationHistoryAdminSerializer - - @extend_schema(responses={HTTP_200_OK: NotificationHistoryAdminSerializer}) - @action(detail=True, methods=["post"], url_path="retry") - def retry(self, *args: tuple, **kwargs: dict) -> Response: - history: NotificationHistoryBase = self.get_object() - if history.status != NotificationStatus.FAILED: - raise ValidationError(f"재시도는 FAILED 상태에서만 가능합니다. (현재: {history.status})") - - with suppress(Exception): - history.send() - - return Response(data=self.get_serializer(history).data) - - -# ---- Template ----------------------------------------------------------- - @extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_NOTI_EMAIL]) for m in TEMPLATE_CRUD_METHODS}) class EmailNotificationTemplateAdminViewSet(_NotiTemplateAdminActionMixin, ModelViewSet): @@ -116,16 +80,42 @@ class NHNCloudSMSNotificationTemplateAdminViewSet(_NotiTemplateAdminActionMixin, # ---- 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): - queryset = EmailNotificationHistory.objects.filter_active().select_related_with_user("template") + 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): - queryset = NHNCloudKakaoAlimTalkNotificationHistory.objects.filter_active().select_related_with_user("template") + 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): - queryset = NHNCloudSMSNotificationHistory.objects.filter_active().select_related_with_user("template") + serializer_class = NHNCloudSMSNotificationHistoryAdminSerializer + queryset = ( + NHNCloudSMSNotificationHistory.objects.filter_active() + .select_related_with_user("template") + .prefetch_related("sent_to_list") + ) diff --git a/app/core/test/models_test.py b/app/core/test/models_test.py index a0d9d34..8896492 100644 --- a/app/core/test/models_test.py +++ b/app/core/test/models_test.py @@ -20,7 +20,7 @@ def template(system_user): return EmailNotificationTemplate.objects.create( code="t", title="t", - from_address="a@b.c", + sent_from="a@b.c", data='{"title":"x","from_":"f","send_to":"r","body":"b"}', created_by=system_user, updated_by=system_user, diff --git a/app/notification/migrations/0001_initial.py b/app/notification/migrations/0001_initial.py index 22b8885..93456c9 100644 --- a/app/notification/migrations/0001_initial.py +++ b/app/notification/migrations/0001_initial.py @@ -22,7 +22,7 @@ class Migration(migrations.Migration): ("title", models.CharField(db_index=True, max_length=256)), ("description", models.TextField(blank=True, null=True)), ("data", models.TextField()), - ("from_address", models.EmailField(blank=False, max_length=254, null=False)), + ("sent_from", models.CharField(max_length=256)), ( "created_by", models.ForeignKey( @@ -51,6 +51,9 @@ class Migration(migrations.Migration): ), ), ], + options={ + "abstract": False, + }, ), migrations.CreateModel( name="EmailNotificationHistory", @@ -59,7 +62,99 @@ class Migration(migrations.Migration): ("created_at", models.DateTimeField(auto_now_add=True)), ("updated_at", models.DateTimeField(auto_now=True)), ("deleted_at", models.DateTimeField(blank=True, null=True)), - ("send_to", models.CharField(max_length=256)), + ("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", @@ -94,20 +189,20 @@ class Migration(migrations.Migration): ), ), ( - "updated_by", + "history", models.ForeignKey( - null=True, on_delete=PROTECT, - related_name="%(class)s_updated_by", - to=settings.AUTH_USER_MODEL, + related_name="sent_to_list", + to="notification.nhncloudkakaoalimtalknotificationhistory", ), ), ( - "template", + "updated_by", models.ForeignKey( + null=True, on_delete=PROTECT, - related_name="histories", - to="notification.emailnotificationtemplate", + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, ), ), ], @@ -126,7 +221,7 @@ class Migration(migrations.Migration): ("title", models.CharField(db_index=True, max_length=256)), ("description", models.TextField(blank=True, null=True)), ("data", models.TextField()), - ("sender_key", models.CharField(blank=True, max_length=128, null=True)), + ("sent_from", models.CharField(max_length=256)), ( "created_by", models.ForeignKey( @@ -155,15 +250,68 @@ class Migration(migrations.Migration): ), ), ], + 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="NHNCloudKakaoAlimTalkNotificationHistory", + 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)), - ("send_to", models.CharField(max_length=256)), + ("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", @@ -198,20 +346,20 @@ class Migration(migrations.Migration): ), ), ( - "updated_by", + "history", models.ForeignKey( - null=True, on_delete=PROTECT, - related_name="%(class)s_updated_by", - to=settings.AUTH_USER_MODEL, + related_name="sent_to_list", + to="notification.nhncloudsmsnotificationhistory", ), ), ( - "template", + "updated_by", models.ForeignKey( + null=True, on_delete=PROTECT, - related_name="histories", - to="notification.nhncloudkakaoalimtalknotificationtemplate", + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, ), ), ], @@ -219,36 +367,6 @@ class Migration(migrations.Migration): "abstract": False, }, ), - migrations.AddConstraint( - model_name="emailnotificationtemplate", - constraint=models.UniqueConstraint( - condition=models.Q(("deleted_at__isnull", True)), fields=("code",), name="uq_email_noti_template_code" - ), - ), - migrations.AddConstraint( - model_name="emailnotificationtemplate", - constraint=models.UniqueConstraint( - condition=models.Q(("deleted_at__isnull", True)), - fields=("code", "title"), - name="uq_email_noti_template_code_title", - ), - ), - migrations.AddConstraint( - model_name="nhncloudkakaoalimtalknotificationtemplate", - constraint=models.UniqueConstraint( - condition=models.Q(("deleted_at__isnull", True)), - fields=("code",), - name="uq_nhn_cloud_kakao_alimtalk_noti_template_code", - ), - ), - migrations.AddConstraint( - model_name="nhncloudkakaoalimtalknotificationtemplate", - constraint=models.UniqueConstraint( - condition=models.Q(("deleted_at__isnull", True)), - fields=("code", "title"), - name="uq_nhn_cloud_kakao_alimtalk_noti_template_code_title", - ), - ), migrations.CreateModel( name="NHNCloudSMSNotificationTemplate", fields=[ @@ -260,7 +378,7 @@ class Migration(migrations.Migration): ("title", models.CharField(db_index=True, max_length=256)), ("description", models.TextField(blank=True, null=True)), ("data", models.TextField()), - ("from_no", models.CharField(blank=True, max_length=13, null=True)), + ("sent_from", models.CharField(max_length=256)), ( "created_by", models.ForeignKey( @@ -289,15 +407,29 @@ class Migration(migrations.Migration): ), ), ], + 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="NHNCloudSMSNotificationHistory", + 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)), - ("send_to", models.CharField(max_length=256)), + ("recipient", models.CharField(max_length=256)), ("context", models.JSONField(default=dict)), ( "status", @@ -332,33 +464,88 @@ class Migration(migrations.Migration): ), ), ( - "updated_by", + "history", models.ForeignKey( - null=True, on_delete=PROTECT, - related_name="%(class)s_updated_by", - to=settings.AUTH_USER_MODEL, + related_name="sent_to_list", + to="notification.emailnotificationhistory", ), ), ( - "template", + "updated_by", models.ForeignKey( + null=True, on_delete=PROTECT, - related_name="histories", - to="notification.nhncloudsmsnotificationtemplate", + 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_nhn_cloud_sms_noti_template_code", + name="uq_notification_nhncloudsmsnotificationtemplate_code", ), ), migrations.AddConstraint( @@ -366,7 +553,7 @@ class Migration(migrations.Migration): constraint=models.UniqueConstraint( condition=models.Q(("deleted_at__isnull", True)), fields=("code", "title"), - name="uq_nhn_cloud_sms_noti_template_code_title", + name="uq_notification_nhncloudsmsnotificationtemplate_code_title", ), ), ] diff --git a/app/notification/models/__init__.py b/app/notification/models/__init__.py index a756b96..6ca5c08 100644 --- a/app/notification/models/__init__.py +++ b/app/notification/models/__init__.py @@ -1,17 +1,26 @@ -from .base import UnhandledVariableHandling -from .email import EmailNotificationHistory, EmailNotificationTemplate +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, NHNCloudSMSNotificationTemplate +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 index dadd59b..1850f1d 100644 --- a/app/notification/models/base.py +++ b/app/notification/models/base.py @@ -1,16 +1,19 @@ from enum import StrEnum, auto from json import loads as json_loads from logging import getLogger -from typing import Any, ClassVar +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 -from django.db import models +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") @@ -28,6 +31,22 @@ class NotificationStatus(models.TextChoices): 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] = "}}" @@ -38,16 +57,32 @@ class NotificationTemplateBase(BaseAbstractModel): 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", + ), + ] - def _to_dtl(self, source: str) -> str: + @classmethod + def _to_dtl(cls, source: str) -> str: return source - @staticmethod - def _extract_root_variables(template: Template) -> set[str]: + @classmethod + def _extract_root_variables(cls, source: str) -> set[str]: roots: set[str] = set() - for node in template.nodelist.get_nodes_by_type(VariableNode): + 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 @@ -56,46 +91,110 @@ def _extract_root_variables(template: Template) -> set[str]: @property def template_variables(self) -> set[str]: - return self._extract_root_variables(Template(self._to_dtl(self.data))) - - def render( - self, - context: dict[str, str], - undefined_variable_handling: UnhandledVariableHandling = UnhandledVariableHandling.RAISE, - ) -> dict[str, Any]: - template = Template(self._to_dtl(self.data)) - context = dict(context) - missing = self._extract_root_variables(template) - context.keys() - - if missing and undefined_variable_handling is UnhandledVariableHandling.RAISE: - raise ValueError( - f"Template '{self.code}' rendered without required context variables: {sorted(missing)}", - ) + # 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) - for key in missing: - match undefined_variable_handling: - case UnhandledVariableHandling.SHOW_AS_TEMPLATE_VAR: - context[key] = f"{self.variable_start} {key} {self.variable_end}" - case UnhandledVariableHandling.RANDOM: - context[key] = f"RandomValue-{uuid4().hex[:8]}" - case UnhandledVariableHandling.REMOVE: - context[key] = "" + all_vars: set[str] = set() - return json_loads(template.render(Context(context))) + def collect(s: str) -> str: + all_vars.update(type(self)._extract_root_variables(s)) + return s - def render_as_html( - self, - context: dict[str, str], - undefined_variable_handling: UnhandledVariableHandling = UnhandledVariableHandling.RANDOM, - ) -> str: - rendered_context = self.render(context=context, undefined_variable_handling=undefined_variable_handling) - return get_template(self.html_template_name).render(rendered_context) + _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] - send_to = models.CharField(max_length=256) + 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, @@ -106,28 +205,81 @@ class NotificationHistoryBase(BaseAbstractModel): 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 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 + try: + payload = json_loads(self.history.template_data) + except ValueError: + payload = self.history.template_data + + rendered_context = dict(self.context) + all_vars: set[str] = set() + _walk_strings(payload, lambda s: all_vars.update(template_class._extract_root_variables(s)) or s) + missing = all_vars - 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 template_code(self) -> str: - raise NotImplementedError("Subclasses must implement template_code") + 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: - raise NotImplementedError("Subclasses must implement build_send_parameters") + 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.client.send_message(data=self.build_send_parameters()) + 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 send_to=%s", - self.id, - self.template_code, - self.send_to, + "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 index 8471d1e..fe0fc6d 100644 --- a/app/notification/models/email.py +++ b/app/notification/models/email.py @@ -1,48 +1,42 @@ from typing import ClassVar -from core.external_apis.__interface__ import SendParameters from core.external_apis.smtp_email import EmailClient, email_client from django.db import models -from notification.models.base import NotificationHistoryBase, NotificationTemplateBase +from notification.models.base import ( + NotificationHistoryBase, + NotificationHistoryQuerySet, + NotificationHistorySentToBase, + NotificationTemplateBase, +) class EmailNotificationTemplate(NotificationTemplateBase): html_template_name: ClassVar[str] = "email_preview.html" - from_address = models.EmailField(null=False, blank=False) - class Meta: - constraints = [ - models.UniqueConstraint( - fields=["code"], - condition=models.Q(deleted_at__isnull=True), - name="uq_email_noti_template_code", - ), - models.UniqueConstraint( - fields=["code", "title"], - condition=models.Q(deleted_at__isnull=True), - name="uq_email_noti_template_code_title", - ), - ] +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, ) - @property - def template_code(self) -> str: - return self.template.code - - def build_send_parameters(self) -> SendParameters: - return SendParameters( - payload=self.template.render(context=self.context), - send_to=self.send_to, - template_code=self.template_code, - sent_from=self.template.from_address, - ) + 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 index 4909b4d..24e2dc9 100644 --- a/app/notification/models/nhn_cloud_kakao_alimtalk.py +++ b/app/notification/models/nhn_cloud_kakao_alimtalk.py @@ -1,13 +1,17 @@ from re import compile as re_compile from typing import Any, ClassVar, Self -from core.external_apis.__interface__ import SendParameters 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, NotificationTemplateBase +from notification.models.base import ( + NotificationHistoryBase, + NotificationHistoryQuerySet, + NotificationHistorySentToBase, + NotificationTemplateBase, +) from user.models import UserExt _KAKAO_VAR_RE = re_compile(r"#\{(\w+)\}") @@ -53,7 +57,7 @@ def sync_with_nhn_cloud(self) -> Self: code=code, title=ext["templateName"], description="", - sender_key=ext["senderKey"], + sent_from=ext["senderKey"], data=default_json_dumps(ext), created_by=system_user, updated_by=system_user, @@ -69,16 +73,16 @@ def sync_with_nhn_cloud(self) -> Self: continue new_data = default_json_dumps(ext) - if row.title != ext["templateName"] or row.sender_key != ext["senderKey"] or row.data != new_data: + if row.title != ext["templateName"] or row.sent_from != ext["senderKey"] or row.data != new_data: row.title = ext["templateName"] - row.sender_key = ext["senderKey"] + 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", "sender_key", "data", "updated_at", "updated_by"], + fields=["title", "sent_from", "data", "updated_at", "updated_by"], batch_size=100, ) @@ -92,38 +96,49 @@ class NHNCloudKakaoAlimTalkNotificationTemplate(NotificationTemplateBase): variable_end: ClassVar[str] = "}" html_template_name: ClassVar[str] = "nhn_cloud_kakao_alimtalk_preview.html" - sender_key = models.CharField(max_length=128, null=True, blank=True) - objects: NHNCloudKakaoAlimTalkNotificationTemplateQuerySet = ( NHNCloudKakaoAlimTalkNotificationTemplateQuerySet.as_manager() # type: ignore[misc, assignment] ) - class Meta: - constraints = [ - models.UniqueConstraint( - fields=["code"], - condition=models.Q(deleted_at__isnull=True), - name="uq_nhn_cloud_kakao_alimtalk_noti_template_code", - ), - models.UniqueConstraint( - fields=["code", "title"], - condition=models.Q(deleted_at__isnull=True), - name="uq_nhn_cloud_kakao_alimtalk_noti_template_code_title", - ), - ] - 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) - def _to_dtl(self, source: str) -> str: + @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 미리보기용으로만 사용됨.) + 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, @@ -131,14 +146,6 @@ class NHNCloudKakaoAlimTalkNotificationHistory(NotificationHistoryBase): related_name="histories", ) - @property - def template_code(self) -> str: - return self.template.code - - def build_send_parameters(self) -> SendParameters: - return SendParameters( - payload=self.context, - send_to=self.send_to, - template_code=self.template_code, - sent_from=self.template.sender_key, - ) + 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 index e052790..f963709 100644 --- a/app/notification/models/nhn_cloud_sms.py +++ b/app/notification/models/nhn_cloud_sms.py @@ -1,48 +1,42 @@ from typing import ClassVar -from core.external_apis.__interface__ import SendParameters from core.external_apis.nhn_cloud_sms import NHNCloudSMSClient, nhn_cloud_sms_client from django.db import models -from notification.models.base import NotificationHistoryBase, NotificationTemplateBase +from notification.models.base import ( + NotificationHistoryBase, + NotificationHistoryQuerySet, + NotificationHistorySentToBase, + NotificationTemplateBase, +) class NHNCloudSMSNotificationTemplate(NotificationTemplateBase): html_template_name: ClassVar[str] = "nhn_cloud_sms_preview.html" - from_no = models.CharField(max_length=13, null=True, blank=True) - class Meta: - constraints = [ - models.UniqueConstraint( - fields=["code"], - condition=models.Q(deleted_at__isnull=True), - name="uq_nhn_cloud_sms_noti_template_code", - ), - models.UniqueConstraint( - fields=["code", "title"], - condition=models.Q(deleted_at__isnull=True), - name="uq_nhn_cloud_sms_noti_template_code_title", - ), - ] +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, ) - @property - def template_code(self) -> str: - return self.template.code - - def build_send_parameters(self) -> SendParameters: - return SendParameters( - payload=self.template.render(context=self.context), - send_to=self.send_to, - template_code=self.template_code, - sent_from=self.template.from_no, - ) + objects: NHNCloudSMSNotificationHistoryQuerySet = ( + NHNCloudSMSNotificationHistoryQuerySet.as_manager() # type: ignore[misc, assignment] + ) diff --git a/app/notification/test/history_send_test.py b/app/notification/test/history_send_test.py index 8db6535..b80c710 100644 --- a/app/notification/test/history_send_test.py +++ b/app/notification/test/history_send_test.py @@ -2,7 +2,7 @@ from unittest.mock import patch import pytest -from notification.models import EmailNotificationHistory, EmailNotificationTemplate +from notification.models import EmailNotificationHistory, EmailNotificationHistorySentTo, EmailNotificationTemplate from notification.models.base import NotificationStatus from user.models import UserExt @@ -17,69 +17,79 @@ def email_template(system_user): return EmailNotificationTemplate.objects.create( code="c", title="t", - from_address="from@example.com", + 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_history_initial_status_is_created(email_template): - history = email_template.histories.create(send_to="to@example.com", context={}) - assert history.status == NotificationStatus.CREATED +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_history_success_transitions_to_sent(email_template): - history = email_template.histories.create(send_to="to@example.com", context={}) +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: - history.send() + sent_to.send() mock_client.send_message.assert_called_once() - history.refresh_from_db() - assert history.status == NotificationStatus.SENT + sent_to.refresh_from_db() + assert sent_to.status == NotificationStatus.SENT @pytest.mark.django_db -def test_history_failure_transitions_to_failed_and_propagates(email_template): - history = email_template.histories.create(send_to="to@example.com", context={}) +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"): - history.send() - history.refresh_from_db() - assert history.status == NotificationStatus.FAILED + sent_to.send() + sent_to.refresh_from_db() + assert sent_to.status == NotificationStatus.FAILED @pytest.mark.django_db -def test_history_failure_logs_to_slack_logger(email_template, caplog): - history = email_template.histories.create(send_to="bad@example.com", context={}) +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): - history.send() - # slack_logger에 ERROR 레벨로 기록되고, exc_info가 첨부되어야 함 + 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 "send_to=bad@example.com" in records[0].getMessage() + assert "recipient=bad@example.com" in records[0].getMessage() @pytest.mark.django_db def test_history_send_parameters_uses_rendered_payload(system_user): - # build_send_parameters가 template.render(context)를 통과시키는지 확인 tpl = EmailNotificationTemplate.objects.create( code="render", title="t", - from_address="a@b.c", + sent_from="a@b.c", data='{"title":"안녕 {{ name }}","from_":"f","send_to":"r","body":"b"}', created_by=system_user, updated_by=system_user, ) - history = tpl.histories.create(send_to="to@example.com", context={"name": "길동"}) - params = history.build_send_parameters() + 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" @@ -88,23 +98,127 @@ def test_history_send_parameters_uses_rendered_payload(system_user): @pytest.mark.django_db def test_history_template_code_property_returns_template_code(email_template): - history = email_template.histories.create(send_to="to@example.com", context={}) + history = _create_history(email_template) assert history.template_code == email_template.code @pytest.mark.django_db -def test_history_send_fails_fast_when_context_missing_template_variables(system_user): - # 발송 시 context에 누락된 템플릿 변수가 있으면 RANDOM 텍스트로 채워 보내는 게 아니라 즉시 실패해야 함. +def test_send_fails_fast_when_context_missing_template_variables(system_user): + # 발송 시 sent_to.context에 누락된 템플릿 변수가 있으면 즉시 실패해야 함. tpl = EmailNotificationTemplate.objects.create( code="missing-var", title="t", - from_address="a@b.c", + sent_from="a@b.c", data='{"title":"안녕 {{ name }}님","from_":"f","send_to":"r","body":"{{ message }}"}', created_by=system_user, updated_by=system_user, ) - history = tpl.histories.create(send_to="to@example.com", context={"name": "길동"}) + history = _create_history(tpl, context={"name": "길동"}) + sent_to = history.sent_to_list.get() with pytest.raises(ValueError, match="message"): - history.send() + 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.status == NotificationStatus.FAILED + 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 index 50d599d..754b509 100644 --- a/app/notification/test/kakao_sync_test.py +++ b/app/notification/test/kakao_sync_test.py @@ -44,7 +44,7 @@ def test_sync_creates_new_external_templates(mock_nhn_client): assert Template.objects.filter_active().count() == 1 row = Template.objects.get(code="T1") assert row.title == "Hi" - assert row.sender_key == "S1" + assert row.sent_from == "S1" @pytest.mark.django_db @@ -53,7 +53,7 @@ def test_sync_updates_changed_templates(system_user, mock_nhn_client): existing = Template( code="X", title="OLD", - sender_key="S1", + sent_from="S1", description="", data='{"templateCode":"X","templateName":"OLD","senderKey":"S1","templateContent":"old","status":"TSC03"}', created_by=system_user, @@ -90,7 +90,7 @@ def test_sync_soft_deletes_missing_templates(system_user, mock_nhn_client): Template( code="GONE", title="g", - sender_key="S1", + sent_from="S1", description="", data="{}", created_by=system_user, diff --git a/app/notification/test/sms_test.py b/app/notification/test/sms_test.py index 79750b4..37c7cdc 100644 --- a/app/notification/test/sms_test.py +++ b/app/notification/test/sms_test.py @@ -17,7 +17,7 @@ def sms_template_in_memory(): return NHNCloudSMSNotificationTemplate( code="welcome-sms", title="Welcome SMS", - from_no="0212345678", + sent_from="0212345678", data='{"body":"안녕하세요 {{ name }}님"}', ) @@ -27,7 +27,7 @@ def mms_template_in_memory(): return NHNCloudSMSNotificationTemplate( code="welcome-mms", title="Welcome MMS", - from_no="0212345678", + sent_from="0212345678", data='{"title":"공지 {{ event }}","body":"안녕하세요 {{ name }}님"}', ) @@ -37,7 +37,7 @@ def sms_template_persisted(system_user): return NHNCloudSMSNotificationTemplate.objects.create( code="welcome-sms", title="Welcome SMS", - from_no="0212345678", + sent_from="0212345678", data='{"body":"안녕하세요 {{ name }}님"}', created_by=system_user, updated_by=system_user, @@ -49,30 +49,30 @@ def mms_template_persisted(system_user): return NHNCloudSMSNotificationTemplate.objects.create( code="welcome-mms", title="Welcome MMS", - from_no="0212345678", + sent_from="0212345678", data='{"title":"공지 {{ event }}","body":"안녕하세요 {{ name }}님"}', created_by=system_user, updated_by=system_user, ) -# ---- 템플릿 render() -------------------------------------------------------- +# ---- SentTo.render() (template.build_preview_sent_to 경유) ------------------ def test_sms_short_render_returns_body_only(sms_template_in_memory): - result = sms_template_in_memory.render({"name": "길동"}) + 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.render({"event": "PyCon", "name": "길동"}) + 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.render({}, UnhandledVariableHandling.REMOVE) + result = tpl.build_preview_sent_to({}).render(UnhandledVariableHandling.REMOVE) assert result["body"] == "hello" assert result["title"] == "" @@ -81,30 +81,38 @@ def test_sms_render_does_not_raise_when_title_empty_but_body_present(): def test_sms_preview_short_renders_body(sms_template_in_memory): - html = sms_template_in_memory.render_as_html({"name": "길동"}) + html = sms_template_in_memory.build_preview_sent_to({"name": "길동"}).render_as_html() assert html.strip().startswith(" Date: Fri, 1 May 2026 22:02:32 +0900 Subject: [PATCH 5/5] =?UTF-8?q?chore:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/models.py | 2 +- app/core/util/google_api.py | 6 ++-- app/external_api/google_oauth2/views.py | 8 ++++- app/notification/models/base.py | 30 ++++++++++++++----- .../models/nhn_cloud_kakao_alimtalk.py | 3 +- 5 files changed, 36 insertions(+), 13 deletions(-) diff --git a/app/core/models.py b/app/core/models.py index 73c1add..1aa98b3 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -18,7 +18,7 @@ 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: + 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) diff --git a/app/core/util/google_api.py b/app/core/util/google_api.py index 1a6288e..da62351 100644 --- a/app/core/util/google_api.py +++ b/app/core/util/google_api.py @@ -27,13 +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 -) -> tuple[str, str | None]: - url, _ = 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", ) - return url, flow.code_verifier + 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 b63c839..2f18f8b 100644 --- a/app/external_api/google_oauth2/views.py +++ b/app/external_api/google_oauth2/views.py @@ -49,8 +49,9 @@ def authorize_google_oauth2(self, request: request.Request, *args, **kwargs) -> if not (flow := create_oauth_flow()): return self._response_500("Google OAuth is not configured.") - url, code_verifier = 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") @@ -58,6 +59,11 @@ def redirect_google_oauth2(self, request: request.Request, *args, **kwargs) -> r 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.") diff --git a/app/notification/models/base.py b/app/notification/models/base.py index 1850f1d..a6b7d52 100644 --- a/app/notification/models/base.py +++ b/app/notification/models/base.py @@ -213,21 +213,37 @@ class Meta: ), ] + 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 - try: - payload = json_loads(self.history.template_data) - except ValueError: - payload = self.history.template_data + payload = self._parsed_template_data() rendered_context = dict(self.context) - all_vars: set[str] = set() - _walk_strings(payload, lambda s: all_vars.update(template_class._extract_root_variables(s)) or s) - missing = all_vars - rendered_context.keys() + missing = self._required_template_variables(payload) - rendered_context.keys() if missing and undef_var is UnhandledVariableHandling.RAISE: raise ValueError( diff --git a/app/notification/models/nhn_cloud_kakao_alimtalk.py b/app/notification/models/nhn_cloud_kakao_alimtalk.py index 24e2dc9..95f92d8 100644 --- a/app/notification/models/nhn_cloud_kakao_alimtalk.py +++ b/app/notification/models/nhn_cloud_kakao_alimtalk.py @@ -121,7 +121,8 @@ class NHNCloudKakaoAlimTalkNotificationHistorySentTo(NotificationHistorySentToBa @property def payload(self) -> dict[str, Any]: # Kakao 외부 API는 templateParameter dict를 그대로 받으므로 로컬 render 없이 self.context 사용. - # (render() 자체는 admin 미리보기용으로만 사용됨.) + # (render() 자체는 admin 미리보기용으로만 사용됨.) 단, render를 우회하므로 외부 호출 전에 변수 누락만은 명시 검증. + self.assert_context_complete() return self.context