Skip to content

Commit fc25963

Browse files
committed
feat: 수정 심사 요청 생성 시 슬랙 알림을 보내도록 수정
1 parent 054be33 commit fc25963

File tree

7 files changed

+162
-16
lines changed

7 files changed

+162
-16
lines changed

app/core/const/datetime.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from datetime import timedelta, timezone
2+
3+
UTC = timezone.utc
4+
KST = timezone(timedelta(hours=9))

app/core/external_apis/slack/blocks.py

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,23 @@
33

44

55
class SlackChildBlockType(typing.TypedDict):
6-
block_type: typing.Literal["plain_text", "mrkdwn"]
6+
type: typing.Literal["plain_text", "mrkdwn"]
77
text: str
88
emoji: typing.NotRequired[typing.Literal[True]]
99
title: typing.NotRequired[str]
1010

1111

12+
class SlackAccessoryBlockType(typing.TypedDict):
13+
type: str
14+
text: typing.NotRequired[SlackChildBlockType]
15+
url: typing.NotRequired[str]
16+
17+
1218
class SlackParentBlockType(typing.TypedDict):
13-
block_type: str
19+
type: str
1420
text: typing.NotRequired[SlackChildBlockType]
1521
fields: typing.NotRequired[list[SlackChildBlockType]]
22+
accessory: typing.NotRequired[SlackAccessoryBlockType]
1623

1724

1825
class SlackBlocksType(typing.TypedDict):
@@ -22,23 +29,23 @@ class SlackBlocksType(typing.TypedDict):
2229
@dataclasses.dataclass
2330
class SlackChildBlock:
2431
text: str
25-
block_type: typing.Literal["plain_text", "mrkdwn"] = "plain_text"
32+
type: typing.Literal["plain_text", "mrkdwn"] = "plain_text"
2633

2734
def to_dict(self) -> SlackChildBlockType:
28-
result = SlackChildBlockType(block_type=self.block_type, text=self.text)
29-
if self.block_type == "plain_text":
35+
result = SlackChildBlockType(type=self.type, text=self.text)
36+
if self.type == "plain_text":
3037
result["emoji"] = True
3138
return result
3239

3340

3441
@dataclasses.dataclass
3542
class SlackPlainTextChildBlock(SlackChildBlock):
36-
block_type: typing.Literal["plain_text"] = "plain_text"
43+
type: typing.Literal["plain_text"] = "plain_text"
3744

3845

3946
@dataclasses.dataclass
4047
class SlackMarkDownChildBlock(SlackChildBlock):
41-
block_type: typing.Literal["mrkdwn"] = "mrkdwn"
48+
type: typing.Literal["mrkdwn"] = "mrkdwn"
4249

4350

4451
@dataclasses.dataclass
@@ -50,9 +57,31 @@ def __post_init__(self) -> None:
5057
self.text = f"{code_title}```{self.text}```"
5158

5259

60+
@dataclasses.dataclass
61+
class SlackAccessoryBlock:
62+
type: str
63+
text: SlackChildBlock | None = None
64+
65+
def to_dict(self) -> SlackAccessoryBlockType:
66+
result = SlackAccessoryBlockType(type=self.type)
67+
if self.text:
68+
result["text"] = self.text.to_dict()
69+
return result
70+
71+
72+
@dataclasses.dataclass
73+
class SlackURLButtonAccessoryBlock(SlackAccessoryBlock):
74+
url: str = ""
75+
type: typing.Literal["button"] = "button"
76+
text: SlackChildBlock | None = None
77+
78+
def to_dict(self) -> SlackAccessoryBlockType:
79+
return super().to_dict() | {"url": self.url}
80+
81+
5382
@dataclasses.dataclass
5483
class SlackParentBlock:
55-
block_type: str
84+
type: str
5685
text: SlackChildBlock | None = None
5786
fields: list[SlackChildBlock] | None = None
5887

@@ -61,7 +90,7 @@ def __post_init__(self) -> None:
6190
raise ValueError("At least one of text or fields must be set!")
6291

6392
def to_dict(self) -> SlackParentBlockType:
64-
result = SlackParentBlockType(block_type=self.block_type)
93+
result = SlackParentBlockType(type=self.type)
6594
if self.text:
6695
result["text"] = self.text.to_dict()
6796
if self.fields:
@@ -71,24 +100,31 @@ def to_dict(self) -> SlackParentBlockType:
71100

72101
@dataclasses.dataclass
73102
class SlackHeaderParentBlock(SlackParentBlock):
74-
block_type: str = "header"
103+
type: str = "header"
75104

76105
def __post_init__(self) -> None:
77106
super().__post_init__()
78107
if self.fields:
79108
raise ValueError("header block only allows text, not fields!")
80-
if self.text.block_type != "plain_text":
109+
if self.text.type != "plain_text":
81110
raise ValueError("header block only allows plain_text text block!")
82111

83112

84113
@dataclasses.dataclass
85114
class SlackSectionParentBlock(SlackParentBlock):
86-
block_type: str = "section"
115+
type: str = "section"
116+
accessory: SlackAccessoryBlock | None = None
117+
118+
def to_dict(self) -> SlackParentBlockType:
119+
result = super().to_dict()
120+
if self.accessory:
121+
result["accessory"] = self.accessory.to_dict()
122+
return result
87123

88124

89125
@dataclasses.dataclass
90126
class SlackBlocks:
91127
blocks: list[SlackParentBlock]
92128

93129
def to_dict(self) -> SlackBlocksType:
94-
return {"blocks": [b.to_dict() for b in self.blocks]}
130+
return SlackBlocksType(blocks=[b.to_dict() for b in self.blocks])
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from core.util.decorator import retry
2+
from django.conf import settings
3+
from httpx import Timeout, post
4+
5+
from .blocks import SlackBlocks
6+
7+
TimeoutTypes = None | float | tuple[float | None, float | None, float | None, float | None] | Timeout
8+
9+
10+
class SlackClient:
11+
@retry
12+
def send_message(self, channel_id: str, text: str, blocks: SlackBlocks = None, timeout: TimeoutTypes = None):
13+
return post(
14+
"https://www.slack.com/api/chat.postMessage",
15+
headers={"Content-type": "application/json", "Authorization": f"Bearer {settings.SLACK.token}"},
16+
json={"channel": channel_id, "text": text, "blocks": blocks.to_dict()["blocks"]},
17+
timeout=timeout,
18+
follow_redirects=True,
19+
)

app/core/settings.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,11 @@
5656

5757
DEPLOYMENT_RELEASE_VERSION = os.environ.get("DEPLOYMENT_RELEASE_VERSION", "unknown")
5858
# Loggers
59-
SLACK = types.SimpleNamespace(token=env("SLACK_LOG_TOKEN", default=""), channel=env("SLACK_LOG_CHANNEL", default=""))
59+
SLACK = types.SimpleNamespace(
60+
token=env("SLACK_LOG_TOKEN", default=""),
61+
channel=env("SLACK_LOG_CHANNEL", default=""),
62+
modification_audit_notification_channel=env("SLACK_MODIFICATION_AUDIT_NOTIFICATION_CHANNEL", default=""),
63+
)
6064

6165
LOG_LEVEL = env("LOG_LEVEL")
6266
LOGGING = {
@@ -341,6 +345,13 @@
341345
set(env.list("CSRF_TRUSTED_ORIGINS", default=["https://rest-api.pycon.kr"])) | COOKIE_TRUSTED_ORIGIN_SET
342346
)
343347

348+
# Frontend domain settings
349+
FRONTEND_DOMAIN = types.SimpleNamespace(
350+
main=env("FRONTEND_MAIN_DOMAIN", default="https://pycon.kr"),
351+
admin=env("FRONTEND_ADMIN_DOMAIN", default="https://admin.pycon.kr"),
352+
participant=env("FRONTEND_PARTICIPANT_DOMAIN", default="https://participant.pycon.kr"),
353+
)
354+
344355
# Django Rest Framework Settings
345356
REST_FRAMEWORK = {
346357
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.NamespaceVersioning",

app/core/util/decorator.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import collections.abc
2+
import functools
3+
import typing
4+
5+
Param = typing.ParamSpec("Param")
6+
RetType = typing.TypeVar("RetType")
7+
8+
9+
class RetriesFailedException(Exception):
10+
pass
11+
12+
13+
def retry(func: collections.abc.Callable[Param, RetType]) -> collections.abc.Callable[Param, RetType]:
14+
@functools.wraps(wrapped=func)
15+
def wrapper(*args: Param.args, **kwargs: Param.kwargs) -> RetType:
16+
retry_count: int = getattr(args[0] if args else kwargs["self"], "retry_count", 3)
17+
ExceptionClass: type = getattr(args[0] if args else kwargs["self"], "exc_cls", RetriesFailedException)
18+
exc: Exception | None = None
19+
for _ in range(retry_count):
20+
try:
21+
return func(*args, **kwargs)
22+
except Exception as e:
23+
exc = e
24+
raise ExceptionClass(f"Failed after {retry_count} times") from exc
25+
26+
return wrapper

app/participant_portal_api/models.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,37 @@
1+
from __future__ import annotations
2+
13
import types
24
import typing
35

6+
from core.const.datetime import KST
7+
from core.external_apis.slack.blocks import (
8+
SlackBlocks,
9+
SlackHeaderParentBlock,
10+
SlackMarkDownChildBlock,
11+
SlackPlainTextChildBlock,
12+
SlackSectionParentBlock,
13+
SlackURLButtonAccessoryBlock,
14+
)
15+
from core.external_apis.slack.client import SlackClient
416
from core.models import BaseAbstractModel, BaseAbstractModelQuerySet
517
from core.util.django_orm import (
618
apply_diff_to_jsonized_models,
719
apply_diff_to_model,
820
json_to_simplenamespace,
921
model_to_identifier,
1022
)
23+
from django.conf import settings
1124
from django.contrib.contenttypes.fields import GenericForeignKey
1225
from django.contrib.contenttypes.models import ContentType
1326
from django.db import models
1427
from event.presentation.models import Presentation, PresentationSpeaker
1528
from user.models import UserExt
1629

1730
T = typing.TypeVar("T", bound=models.Model)
31+
AUDIT_TYPE: dict[models.Model, str] = {
32+
Presentation: "발표",
33+
UserExt: "프로필",
34+
}
1835

1936

2037
class ModificationAuditQuerySet(BaseAbstractModelQuerySet):
@@ -88,6 +105,37 @@ def apply_modification(self) -> models.Model:
88105
self.instance.refresh_from_db()
89106
return self.instance
90107

108+
def notify_creation_to_slack(self) -> None:
109+
if not (audit_noti_channel := settings.SLACK.modification_audit_notification_channel):
110+
return
111+
112+
created_at_kst_str = self.created_at.astimezone(KST).strftime("%y년 %m월 %d일 %H시 %M분")
113+
audit_instance_type_str = AUDIT_TYPE.get(self.instance_type.model_class(), "알 수 없음")
114+
blocks = SlackBlocks(
115+
blocks=[
116+
SlackHeaderParentBlock(text=SlackPlainTextChildBlock(text=":pencil: 수정 요청이 들어왔어요!")),
117+
SlackSectionParentBlock(
118+
fields=[
119+
SlackMarkDownChildBlock(text=f"*수정 유형*\n{audit_instance_type_str}"),
120+
SlackMarkDownChildBlock(text=f"*요청 시간*\n{created_at_kst_str}"),
121+
SlackMarkDownChildBlock(text=f"*요청자*\n{self.created_by.nickname}"),
122+
SlackMarkDownChildBlock(text=f"*요청 ID*\n{self.id}"),
123+
]
124+
),
125+
SlackSectionParentBlock(
126+
text=SlackMarkDownChildBlock(text="어드민에서 수정 내역을 확인 후 승인 또는 반려해주세요."),
127+
accessory=SlackURLButtonAccessoryBlock(
128+
text=SlackPlainTextChildBlock(text="수정 심사 페이지 열기"),
129+
url=f"{settings.FRONTEND_DOMAIN.admin}/modification-audit/{self.id}",
130+
),
131+
),
132+
]
133+
)
134+
135+
SlackClient().send_message(
136+
channel_id=audit_noti_channel, text="새로운 수정 요청이 도착했습니다.", blocks=blocks
137+
)
138+
91139

92140
class ModificationAuditComment(BaseAbstractModel):
93141
audit = models.ForeignKey(ModificationAudit, on_delete=models.PROTECT, related_name="comments")

app/participant_portal_api/serializers/modification_audit.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,12 +141,14 @@ def save(self, **kwargs: dict) -> types.SimpleNamespace:
141141
if not (diff_data := get_diff_data_from_jsonized_models(original_data, updated_data)):
142142
raise serializers.ValidationError("변경된 데이터가 없습니다.\nNo modification data provided.")
143143

144-
return ModificationAudit.objects.create(
144+
audit: ModificationAudit = ModificationAudit.objects.create(
145145
instance_type=instance_type,
146146
instance_id=instance_key,
147147
original_data=original_data,
148148
modification_data=diff_data,
149-
).fake_modified_instance
149+
)
150+
audit.notify_creation_to_slack()
151+
return audit.fake_modified_instance
150152

151153

152154
class ModificationAuditCancelPortalSerializer(serializers.ModelSerializer):

0 commit comments

Comments
 (0)