django-slack-tools 0.2.2__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. django_slack_tools/__init__.py +0 -1
  2. django_slack_tools/app_settings.py +54 -90
  3. django_slack_tools/locale/ko_KR/LC_MESSAGES/django.po +150 -186
  4. django_slack_tools/py.typed +0 -0
  5. django_slack_tools/slack_messages/admin/message.py +1 -52
  6. django_slack_tools/slack_messages/admin/message_recipient.py +2 -2
  7. django_slack_tools/slack_messages/admin/messaging_policy.py +5 -5
  8. django_slack_tools/slack_messages/backends/__init__.py +3 -3
  9. django_slack_tools/slack_messages/backends/base.py +39 -203
  10. django_slack_tools/slack_messages/backends/dummy.py +0 -12
  11. django_slack_tools/slack_messages/backends/{logging.py → logging_.py} +0 -6
  12. django_slack_tools/slack_messages/backends/slack.py +21 -75
  13. django_slack_tools/slack_messages/message_templates/__init__.py +5 -0
  14. django_slack_tools/slack_messages/message_templates/base.py +17 -0
  15. django_slack_tools/{utils/template → slack_messages/message_templates}/django.py +30 -25
  16. django_slack_tools/slack_messages/message_templates/python.py +38 -0
  17. django_slack_tools/slack_messages/messenger.py +158 -0
  18. django_slack_tools/slack_messages/middlewares/__init__.py +4 -0
  19. django_slack_tools/slack_messages/middlewares/base.py +34 -0
  20. django_slack_tools/slack_messages/middlewares/django.py +218 -0
  21. django_slack_tools/slack_messages/migrations/0001_initial.py +4 -4
  22. django_slack_tools/slack_messages/migrations/0005_alter_slackmessagingpolicy_template_type.py +46 -0
  23. django_slack_tools/slack_messages/migrations/0006_alter_slackmessage_id.py +26 -0
  24. django_slack_tools/slack_messages/models/mention.py +1 -1
  25. django_slack_tools/slack_messages/models/message.py +5 -2
  26. django_slack_tools/slack_messages/models/message_recipient.py +1 -1
  27. django_slack_tools/slack_messages/models/messaging_policy.py +4 -4
  28. django_slack_tools/slack_messages/request.py +91 -0
  29. django_slack_tools/slack_messages/response.py +21 -0
  30. django_slack_tools/slack_messages/shortcuts.py +78 -0
  31. django_slack_tools/slack_messages/tasks.py +16 -49
  32. django_slack_tools/slack_messages/template_loaders/__init__.py +11 -0
  33. django_slack_tools/slack_messages/template_loaders/base.py +16 -0
  34. django_slack_tools/slack_messages/template_loaders/django.py +74 -0
  35. django_slack_tools/slack_messages/template_loaders/errors.py +9 -0
  36. django_slack_tools/{utils/slack/django.py → slack_messages/validators.py} +1 -1
  37. django_slack_tools/utils/django/__init__.py +0 -0
  38. django_slack_tools/utils/import_helper.py +37 -0
  39. django_slack_tools/utils/repr.py +10 -0
  40. django_slack_tools/utils/slack/__init__.py +1 -9
  41. {django_slack_tools-0.2.2.dist-info → django_slack_tools-0.3.0.dist-info}/METADATA +12 -60
  42. django_slack_tools-0.3.0.dist-info/RECORD +58 -0
  43. {django_slack_tools-0.2.2.dist-info → django_slack_tools-0.3.0.dist-info}/WHEEL +1 -1
  44. django_slack_tools/slack_messages/message.py +0 -110
  45. django_slack_tools/utils/slack/message.py +0 -93
  46. django_slack_tools/utils/template/__init__.py +0 -5
  47. django_slack_tools/utils/template/base.py +0 -17
  48. django_slack_tools/utils/template/dict.py +0 -52
  49. django_slack_tools-0.2.2.dist-info/RECORD +0 -43
  50. /django_slack_tools/utils/{model_mixins.py → django/model_mixins.py} +0 -0
  51. /django_slack_tools/utils/{widgets.py → django/widgets.py} +0 -0
  52. {django_slack_tools-0.2.2.dist-info → django_slack_tools-0.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -6,13 +6,13 @@ from typing import TYPE_CHECKING, cast
6
6
  from django.contrib import admin, messages
7
7
  from django.contrib.admin.filters import DateFieldListFilter
8
8
  from django.db.models import Count
9
- from django.db.models.query import QuerySet
10
9
  from django.utils.translation import gettext_lazy as _
11
10
 
12
11
  from django_slack_tools.app_settings import app_settings
13
12
  from django_slack_tools.slack_messages.models import SlackMessageRecipient
14
13
 
15
14
  if TYPE_CHECKING:
15
+ from django.db.models.query import QuerySet
16
16
  from django.http import HttpRequest
17
17
 
18
18
  class SlackMessageRecipientWithAnnotates(SlackMessageRecipient): # noqa: D101
@@ -25,7 +25,7 @@ class SlackMessageRecipientAdmin(admin.ModelAdmin):
25
25
 
26
26
  def get_queryset(self, request: HttpRequest) -> QuerySet[SlackMessageRecipientWithAnnotates]: # noqa: D102
27
27
  return cast(
28
- QuerySet["SlackMessageRecipientWithAnnotates"], # Unsafe force type casting
28
+ "QuerySet[SlackMessageRecipientWithAnnotates]", # Unsafe force type casting
29
29
  super()
30
30
  .get_queryset(request)
31
31
  .annotate(
@@ -7,15 +7,15 @@ from django.contrib import admin
7
7
  from django.contrib.admin.filters import DateFieldListFilter
8
8
  from django.db import models
9
9
  from django.db.models import Count
10
- from django.db.models.query import QuerySet
11
10
  from django.utils.html import format_html
12
11
  from django.utils.translation import gettext_lazy as _
13
12
 
14
13
  from django_slack_tools.slack_messages.models import SlackMessagingPolicy
14
+ from django_slack_tools.utils.django.widgets import JSONWidget
15
15
  from django_slack_tools.utils.slack import get_block_kit_builder_url
16
- from django_slack_tools.utils.widgets import JSONWidget
17
16
 
18
17
  if TYPE_CHECKING:
18
+ from django.db.models.query import QuerySet
19
19
  from django.http import HttpRequest
20
20
  from django_stubs_ext import StrOrPromise
21
21
 
@@ -35,7 +35,7 @@ class SlackMessagingPolicyAdmin(admin.ModelAdmin):
35
35
 
36
36
  def get_queryset(self, request: HttpRequest) -> QuerySet[SlackMessagingPolicyWithAnnotates]: # noqa: D102
37
37
  return cast(
38
- QuerySet["SlackMessagingPolicyWithAnnotates"], # Unsafe force type casting
38
+ "QuerySet[SlackMessagingPolicyWithAnnotates]", # Unsafe force type casting
39
39
  super()
40
40
  .get_queryset(request)
41
41
  .annotate(
@@ -90,7 +90,7 @@ class SlackMessagingPolicyAdmin(admin.ModelAdmin):
90
90
  # ------------------------------------------------------------------------
91
91
  date_hierarchy = "last_modified"
92
92
  search_fields = ("code",)
93
- list_display = ("id", "code", "enabled", "_count_recipients", "created", "last_modified")
93
+ list_display = ("id", "code", "enabled", "_count_recipients", "template_type", "created", "last_modified")
94
94
  list_display_links = ("id", "code")
95
95
  list_filter = (
96
96
  "enabled",
@@ -107,7 +107,7 @@ class SlackMessagingPolicyAdmin(admin.ModelAdmin):
107
107
  (
108
108
  None,
109
109
  {
110
- "fields": ("code", "enabled", "recipients", "header_defaults", "template"),
110
+ "fields": ("code", "enabled", "recipients", "header_defaults", "template_type", "template"),
111
111
  },
112
112
  ),
113
113
  (
@@ -1,12 +1,12 @@
1
1
  from .base import BaseBackend
2
2
  from .dummy import DummyBackend
3
- from .logging import LoggingBackend
3
+ from .logging_ import LoggingBackend
4
4
  from .slack import SlackBackend, SlackRedirectBackend
5
5
 
6
6
  __all__ = (
7
7
  "BaseBackend",
8
- "SlackBackend",
9
- "SlackRedirectBackend",
10
8
  "DummyBackend",
11
9
  "LoggingBackend",
10
+ "SlackBackend",
11
+ "SlackRedirectBackend",
12
12
  )
@@ -2,228 +2,64 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import dataclasses
6
5
  import traceback
7
6
  from abc import ABC, abstractmethod
8
7
  from logging import getLogger
9
- from typing import TYPE_CHECKING, Any, cast
8
+ from typing import TYPE_CHECKING, Any, Optional, cast
10
9
 
11
10
  from slack_sdk.errors import SlackApiError
12
11
 
13
- from django_slack_tools.slack_messages.models import SlackMessage, SlackMessagingPolicy
14
- from django_slack_tools.utils.slack import MessageBody
15
- from django_slack_tools.utils.template import DictTemplate, DjangoTemplate
12
+ from django_slack_tools.slack_messages.response import MessageResponse
16
13
 
17
14
  if TYPE_CHECKING:
18
15
  from slack_sdk.web import SlackResponse
19
16
 
20
- from django_slack_tools.slack_messages.models.mention import SlackMention
21
- from django_slack_tools.slack_messages.models.message_recipient import SlackMessageRecipient
22
- from django_slack_tools.utils.slack import MessageHeader
23
- from django_slack_tools.utils.template import BaseTemplate
17
+ from django_slack_tools.slack_messages.request import MessageBody, MessageHeader, MessageRequest
24
18
 
25
- logger = getLogger(__name__)
26
19
 
27
- RESERVED_CONTEXT_KWARGS = frozenset({"policy", "mentions", "mentions_as_str"})
28
- """Set of reserved context keys automatically created."""
20
+ logger = getLogger(__name__)
29
21
 
30
22
 
31
23
  class BaseBackend(ABC):
32
24
  """Abstract base class for messaging backends."""
33
25
 
34
- def prepare_message(
35
- self,
36
- *,
37
- policy: SlackMessagingPolicy | None = None,
38
- channel: str,
39
- header: MessageHeader,
40
- body: MessageBody,
41
- ) -> SlackMessage:
42
- """Prepare message.
43
-
44
- Args:
45
- policy: Related policy instance.
46
- channel: Channel to send message.
47
- header: Slack message control header.
48
- body: Slack message body.
49
-
50
- Returns:
51
- Prepared message.
52
- """
53
- _header: dict = policy.header_defaults if policy else {}
54
- _header.update(dataclasses.asdict(header))
55
-
56
- _body = dataclasses.asdict(body)
57
-
58
- return SlackMessage(policy=policy, channel=channel, header=_header, body=_body)
59
-
60
- def prepare_messages_from_policy(
61
- self,
62
- policy: SlackMessagingPolicy,
63
- *,
64
- header: MessageHeader,
65
- context: dict[str, Any],
66
- ) -> list[SlackMessage]:
67
- """Prepare messages from policy.
68
-
69
- Args:
70
- policy: Policy to create messages from.
71
- header: Common message header.
72
- context: Message context.
73
-
74
- Returns:
75
- Prepared messages.
76
- """
77
- overridden_reserved = RESERVED_CONTEXT_KWARGS & set(context.keys())
78
- if overridden_reserved:
79
- logger.warning(
80
- "Template keyword argument(s) %s reserved for passing mentions, but already exists."
81
- " User provided value will override it.",
82
- ", ".join(f"`{s}`" for s in overridden_reserved),
83
- )
84
-
85
- messages: list[SlackMessage] = []
86
- for recipient in policy.recipients.all():
87
- logger.debug("Sending message to recipient %s", recipient)
88
-
89
- # Initialize template instance
90
- template = self._get_template_instance_from_policy(policy)
91
-
92
- # Prepare rendering arguments
93
- render_context = self._get_default_context(policy=policy, recipient=recipient)
94
- render_context.update(context)
95
- logger.debug("Context prepared as: %r", render_context)
96
-
97
- # Render template and parse as body
98
- rendered = template.render(context=render_context)
99
- body = MessageBody.from_any(rendered)
100
-
101
- # Create message instance
102
- message = self.prepare_message(policy=policy, channel=recipient.channel, header=header, body=body)
103
- messages.append(message)
104
-
105
- return SlackMessage.objects.bulk_create(messages)
106
-
107
- def _get_template_instance_from_policy(self, policy: SlackMessagingPolicy) -> BaseTemplate:
108
- """Get template instance."""
109
- if policy.template_type == SlackMessagingPolicy.TemplateType.DICT:
110
- return DictTemplate(policy.template)
111
-
112
- if policy.template_type == SlackMessagingPolicy.TemplateType.DJANGO:
113
- return DjangoTemplate(file=policy.template)
114
-
115
- if policy.template_type == SlackMessagingPolicy.TemplateType.DJANGO_INLINE:
116
- return DjangoTemplate(inline=policy.template)
117
-
118
- msg = f"Unsupported template type: {policy.template_type!r}"
119
- raise ValueError(msg)
120
-
121
- def _get_default_context(self, *, policy: SlackMessagingPolicy, recipient: SlackMessageRecipient) -> dict[str, Any]:
122
- """Get default context for rendering.
123
-
124
- Following default context keys are created:
26
+ def deliver(self, request: MessageRequest) -> MessageResponse:
27
+ """Deliver message request."""
28
+ if request.body is None:
29
+ msg = "Message body is required."
30
+ raise ValueError(msg)
125
31
 
126
- - `policy`: Policy code.
127
- - `mentions`: List of mentions.
128
- - `mentions_as_str`: Comma-separated joined string of mentions.
129
- """
130
- mentions: list[SlackMention] = list(recipient.mentions.all())
131
- mentions_as_str = ", ".join(mention.mention for mention in mentions)
132
-
133
- return {
134
- "policy": policy.code,
135
- "mentions": mentions,
136
- "mentions_as_str": mentions_as_str,
137
- }
138
-
139
- def send_messages(self, *messages: SlackMessage, raise_exception: bool, get_permalink: bool) -> int:
140
- """Shortcut to send multiple messages.
141
-
142
- Args:
143
- messages: Messages to send.
144
- raise_exception: Whether to propagate exceptions.
145
- get_permalink: Try to get the message permalink via additional Slack API call.
146
-
147
- Returns:
148
- Count of messages sent successfully.
149
- """
150
- num_sent = 0
151
- for message in messages:
152
- sent = self.send_message(message=message, raise_exception=raise_exception, get_permalink=get_permalink)
153
- num_sent += 1 if sent.ok else 0
154
-
155
- return num_sent
156
-
157
- def send_message(
158
- self,
159
- message: SlackMessage,
160
- *,
161
- raise_exception: bool,
162
- get_permalink: bool,
163
- ) -> SlackMessage:
164
- """Send message.
165
-
166
- Args:
167
- message: Prepared message.
168
- raise_exception: Whether to propagate exceptions.
169
- get_permalink: Try to get the message permalink via additional Slack API call.
170
-
171
- Returns:
172
- Message sent to Slack.
173
- """
174
32
  try:
175
- response: SlackResponse
176
- try:
177
- response = self._send_message(message)
178
- except SlackApiError as err:
179
- if raise_exception:
180
- raise
181
-
182
- logger.warning(
183
- "Error occurred while sending message but suppressed because `raise_exception` set.",
184
- exc_info=err,
185
- )
186
- response = err.response
187
-
188
- message.ok = ok = cast(bool, response.get("ok"))
189
- if ok:
190
- # Get message TS if OK
191
- message.ts = cast(str, response.get("ts"))
192
-
193
- # Store thread TS if possible
194
- data: dict[str, Any] = response.get("message", {})
195
- message.parent_ts = data.get("thread_ts", "")
196
-
197
- # Get message permalink
198
- if get_permalink:
199
- message.permalink = self._get_permalink(message=message, raise_exception=raise_exception)
200
-
201
- message.request = self._record_request(response)
202
- message.response = self._record_response(response)
203
- except: # noqa: E722
204
- if raise_exception:
205
- raise
206
-
207
- logger.exception("Error occurred while sending message but suppressed because `raise_exception` set.")
208
- message.exception = traceback.format_exc()
209
- finally:
210
- message.save()
211
-
212
- message.refresh_from_db()
213
- return message
33
+ response = self._send_message(
34
+ channel=request.channel,
35
+ header=request.header,
36
+ body=request.body,
37
+ )
38
+ error = None
39
+ except SlackApiError as err:
40
+ response = err.response
41
+ error = traceback.format_exc()
42
+
43
+ ok = cast("bool", response.get("ok"))
44
+ data: Any
45
+ if ok:
46
+ ts = cast("Optional[str]", response.get("ts", None))
47
+ data = response.get("message", {})
48
+ parent_ts = data.get("thread_ts", None)
49
+ else:
50
+ ts = None
51
+ data = response.data
52
+ parent_ts = None
53
+
54
+ return MessageResponse(
55
+ request=request,
56
+ ok=ok,
57
+ error=error,
58
+ data=data,
59
+ ts=ts,
60
+ parent_ts=parent_ts,
61
+ )
214
62
 
215
63
  @abstractmethod
216
- def _send_message(self, message: SlackMessage) -> SlackResponse:
64
+ def _send_message(self, *, channel: str, header: MessageHeader, body: MessageBody) -> SlackResponse:
217
65
  """Internal implementation of actual 'send message' behavior."""
218
-
219
- @abstractmethod
220
- def _get_permalink(self, *, message: SlackMessage, raise_exception: bool) -> str:
221
- """Get a permalink for given message identifier."""
222
-
223
- @abstractmethod
224
- def _record_request(self, response: SlackResponse) -> Any:
225
- """Extract request data to be recorded. Should return JSON-serializable object."""
226
-
227
- @abstractmethod
228
- def _record_response(self, response: SlackResponse) -> Any:
229
- """Extract response data to be recorded. Should return JSON-serializable object."""
@@ -7,8 +7,6 @@ from typing import Any
7
7
 
8
8
  from slack_sdk.web import SlackResponse
9
9
 
10
- from django_slack_tools.slack_messages.models import SlackMessage
11
-
12
10
  from .base import BaseBackend
13
11
 
14
12
  logger = getLogger(__name__)
@@ -17,9 +15,6 @@ logger = getLogger(__name__)
17
15
  class DummyBackend(BaseBackend):
18
16
  """An dummy backend that does nothing with message."""
19
17
 
20
- def prepare_message(self, *args: Any, **kwargs: Any) -> SlackMessage: # noqa: D102, ARG002
21
- return SlackMessage(header={}, body={})
22
-
23
18
  def _send_message(self, *args: Any, **kwargs: Any) -> SlackResponse: # noqa: ARG002
24
19
  return SlackResponse(
25
20
  client=None,
@@ -30,10 +25,3 @@ class DummyBackend(BaseBackend):
30
25
  headers={},
31
26
  status_code=200,
32
27
  )
33
-
34
- def _get_permalink(self, *, message: SlackMessage, raise_exception: bool) -> str: # noqa: ARG002
35
- return ""
36
-
37
- def _record_request(self, *args: Any, **kwargs: Any) -> Any: ...
38
-
39
- def _record_response(self, *args: Any, **kwargs: Any) -> Any: ...
@@ -20,9 +20,3 @@ class LoggingBackend(DummyBackend):
20
20
  def _send_message(self, *args: Any, **kwargs: Any) -> SlackResponse:
21
21
  logger.debug("Sending an message with following args=%r, kwargs=%r", args, kwargs)
22
22
  return super()._send_message(*args, **kwargs)
23
-
24
- def _record_request(self, *args: Any, **kwargs: Any) -> Any:
25
- logger.debug("Recording request with args=%r, kwargs=%r", args, kwargs)
26
-
27
- def _record_response(self, *args: Any, **kwargs: Any) -> Any:
28
- logger.debug("Recording response with args=%r, kwargs=%r", args, kwargs)
@@ -3,21 +3,18 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from logging import getLogger
6
- from typing import TYPE_CHECKING, Any, Callable
6
+ from typing import TYPE_CHECKING, Any
7
7
 
8
- from django.core.exceptions import ImproperlyConfigured
9
8
  from django.utils.module_loading import import_string
10
9
  from django.utils.translation import gettext_lazy as _
11
10
  from slack_bolt import App
12
- from slack_sdk.errors import SlackApiError
13
11
 
14
12
  from .base import BaseBackend
15
13
 
16
14
  if TYPE_CHECKING:
17
15
  from slack_sdk.web import SlackResponse
18
16
 
19
- from django_slack_tools.slack_messages.models import SlackMessage
20
- from django_slack_tools.utils.slack import MessageBody
17
+ from django_slack_tools.slack_messages.request import MessageBody, MessageHeader
21
18
 
22
19
 
23
20
  logger = getLogger(__name__)
@@ -26,11 +23,7 @@ logger = getLogger(__name__)
26
23
  class SlackBackend(BaseBackend):
27
24
  """Backend actually sending the messages."""
28
25
 
29
- def __init__(
30
- self,
31
- *,
32
- slack_app: App | Callable[[], App] | str,
33
- ) -> None:
26
+ def __init__(self, *, slack_app: App | str) -> None:
34
27
  """Initialize backend.
35
28
 
36
29
  Args:
@@ -39,57 +32,18 @@ class SlackBackend(BaseBackend):
39
32
  if isinstance(slack_app, str):
40
33
  slack_app = import_string(slack_app)
41
34
 
42
- if callable(slack_app):
43
- slack_app = slack_app()
44
-
45
35
  if not isinstance(slack_app, App):
46
- msg = "Couldn't resolve provided app spec into Slack app instance."
47
- raise ImproperlyConfigured(msg)
36
+ msg = f"Expected {App!s} instance, got {type(slack_app)}"
37
+ raise TypeError(msg)
48
38
 
49
39
  self._slack_app = slack_app
50
40
 
51
- def _send_message(self, message: SlackMessage) -> SlackResponse:
52
- header = message.header or {}
53
- body = message.body or {}
54
- return self._slack_app.client.chat_postMessage(channel=message.channel, **header, **body)
55
-
56
- def _get_permalink(self, *, message: SlackMessage, raise_exception: bool = False) -> str:
57
- """Get a permalink for given message identifier."""
58
- if not message.ts:
59
- msg = "Message timestamp is not set, can't retrieve permalink."
60
- raise ValueError(msg)
61
-
62
- try:
63
- _permalink_resp = self._slack_app.client.chat_getPermalink(
64
- channel=message.channel,
65
- message_ts=message.ts,
66
- )
67
- except SlackApiError:
68
- if raise_exception:
69
- raise
70
-
71
- logger.exception(
72
- "Error occurred while sending retrieving message's permalink,"
73
- " but ignored as `raise_exception` not set.",
74
- )
75
- return ""
76
-
77
- return _permalink_resp.get("permalink", default="")
78
-
79
- def _record_request(self, response: SlackResponse) -> dict[str, Any]:
80
- # Remove auth header (token) from request before recording
81
- response.req_args.get("headers", {}).pop("Authorization", None)
82
-
83
- return response.req_args
84
-
85
- def _record_response(self, response: SlackResponse) -> dict[str, Any]:
86
- return {
87
- "http_verb": response.http_verb,
88
- "api_url": response.api_url,
89
- "status_code": response.status_code,
90
- "headers": response.headers,
91
- "data": response.data,
92
- }
41
+ def _send_message(self, *, channel: str, header: MessageHeader, body: MessageBody) -> SlackResponse:
42
+ return self._slack_app.client.chat_postMessage(
43
+ channel=channel,
44
+ **header.model_dump(),
45
+ **body.model_dump(),
46
+ )
93
47
 
94
48
 
95
49
  class SlackRedirectBackend(SlackBackend):
@@ -109,27 +63,19 @@ class SlackRedirectBackend(SlackBackend):
109
63
 
110
64
  super().__init__(slack_app=slack_app)
111
65
 
112
- def prepare_message(self, *args: Any, channel: str, body: MessageBody, **kwargs: Any) -> SlackMessage:
113
- """Prepare message to send, with modified for redirection.
114
-
115
- Args:
116
- args: Positional arguments to pass to super method.
117
- channel: Original channel to send message.
118
- body: Message content.
119
- kwargs: Keyword arguments to pass to super method.
120
-
121
- Returns:
122
- Prepared message instance.
123
- """
124
- # Modify channel to force messages always sent to specific channel
125
- # Add an attachment that informing message has been redirected
66
+ def _send_message(self, *, channel: str, header: MessageHeader, body: MessageBody) -> SlackResponse:
126
67
  if self.inform_redirect:
127
- body.attachments = [
68
+ attachments = body.attachments or []
69
+ attachments.append(
128
70
  self._make_inform_attachment(original_channel=channel),
129
- *(body.attachments or []),
130
- ]
71
+ )
72
+ body.attachments = attachments
131
73
 
132
- return super().prepare_message(*args, channel=self.redirect_channel, body=body, **kwargs)
74
+ return self._slack_app.client.chat_postMessage(
75
+ channel=self.redirect_channel,
76
+ **header.model_dump(),
77
+ **body.model_dump(),
78
+ )
133
79
 
134
80
  def _make_inform_attachment(self, *, original_channel: str) -> dict[str, Any]:
135
81
  msg_redirect_inform = _(
@@ -0,0 +1,5 @@
1
+ from .base import BaseTemplate
2
+ from .django import DjangoTemplate
3
+ from .python import PythonTemplate
4
+
5
+ __all__ = ("BaseTemplate", "DjangoTemplate", "PythonTemplate")
@@ -0,0 +1,17 @@
1
+ # noqa: D100
2
+ from __future__ import annotations
3
+
4
+ from abc import ABC, abstractmethod
5
+ from typing import Any, Generic, TypeVar
6
+
7
+ _T = TypeVar("_T")
8
+
9
+
10
+ class BaseTemplate(ABC, Generic[_T]):
11
+ """Base class for templates."""
12
+
13
+ template: _T
14
+
15
+ @abstractmethod
16
+ def render(self, context: dict[str, Any]) -> Any:
17
+ """Render the template with the given context."""
@@ -16,6 +16,7 @@ if TYPE_CHECKING:
16
16
  from typing import Any
17
17
 
18
18
  from django.template.backends.base import BaseEngine
19
+ from django.template.base import Template
19
20
 
20
21
  logger = logging.getLogger(__name__)
21
22
 
@@ -23,6 +24,8 @@ logger = logging.getLogger(__name__)
23
24
  class DjangoTemplate(BaseTemplate):
24
25
  """Template utilizing Django built-in template engine."""
25
26
 
27
+ template: Template
28
+
26
29
  @overload
27
30
  def __init__(self, *, file: str, engine: BaseEngine | None = None) -> None: ... # pragma: no cover
28
31
 
@@ -45,7 +48,6 @@ class DjangoTemplate(BaseTemplate):
45
48
 
46
49
  Raises:
47
50
  TypeError: Some of the arguments are missing or multiple are provided.
48
- ValueError: Unsupported value provided.
49
51
  """
50
52
  engine = engines["django"] if engine is None else engine
51
53
 
@@ -61,13 +63,11 @@ class DjangoTemplate(BaseTemplate):
61
63
  msg = "Unreachable code"
62
64
  raise NotImplementedError(msg)
63
65
 
64
- self.template = template
65
-
66
- def render(self, *, context: dict[str, Any] | None = None) -> dict: # noqa: D102
67
- context = {} if context is None else context
66
+ self.template = template # type: ignore[assignment] # False-positive error
68
67
 
68
+ def render(self, context: dict[str, Any]) -> Any: # noqa: D102
69
69
  logger.debug("Rendering template with context: %r", context)
70
- rendered = self.template.render(context=context)
70
+ rendered = self.template.render(context=context) # type: ignore[arg-type] # False-positive error
71
71
  return _xml_to_dict(rendered)
72
72
 
73
73
 
@@ -93,7 +93,7 @@ def _xml_to_dict(xml: str) -> dict:
93
93
  xml,
94
94
  attr_prefix="",
95
95
  cdata_key="text",
96
- force_list=("blocks", "elements"),
96
+ force_list=("blocks", "elements", "options"),
97
97
  postprocessor=_xml_postprocessor,
98
98
  )
99
99
  return dict(next(iter(obj.values())))
@@ -101,11 +101,11 @@ def _xml_to_dict(xml: str) -> dict:
101
101
 
102
102
  def _preprocess_xml(xml: str) -> str:
103
103
  """Normalize XML text nodes."""
104
- root = ET.fromstring(xml) # noqa: S314; TODO(lasuillard): Naive belief that XML is safe
104
+ root = ET.fromstring(xml) # noqa: S314 ; TODO(lasuillard): Naive belief that XML is safe
105
105
  for node in root.iter():
106
106
  node.tag = _rename_tag(node.tag)
107
107
 
108
- if node.tag == "text" and node.text:
108
+ if node.tag in ("text", "elements") and node.text:
109
109
  text = dedent(node.text)
110
110
  text = _remove_single_newline(text)
111
111
  logger.debug("Normalized text node: %r -> %r", node.text, text)
@@ -114,22 +114,6 @@ def _preprocess_xml(xml: str) -> str:
114
114
  return ET.tostring(root, encoding="unicode")
115
115
 
116
116
 
117
- def _rename_tag(tag: str) -> str:
118
- """Rename tags."""
119
- if tag == "block":
120
- return "blocks"
121
-
122
- if tag == "element":
123
- return "elements"
124
-
125
- return tag
126
-
127
-
128
- def _remove_single_newline(text: str) -> str:
129
- """Remove a single newline from repeated newlines. If the are just one newline, replace it with space."""
130
- return re.sub(r"([\n]+)", lambda m: "\n" * (m.group(1).count("\n") - 1) or " ", text)
131
-
132
-
133
117
  def _xml_postprocessor(path: Any, key: str, value: Any) -> tuple[str, Any]: # noqa: ARG001
134
118
  if value == "true":
135
119
  return key, True
@@ -137,4 +121,25 @@ def _xml_postprocessor(path: Any, key: str, value: Any) -> tuple[str, Any]: # n
137
121
  if value == "false":
138
122
  return key, False
139
123
 
124
+ # TODO(lasuillard): Should coerce all numeric-like strings to numbers?
125
+ if key == "indent":
126
+ return key, int(value)
127
+
140
128
  return key, value
129
+
130
+
131
+ _TAG_MAPPING = {
132
+ "block": "blocks",
133
+ "element": "elements",
134
+ "option": "options",
135
+ }
136
+
137
+
138
+ def _rename_tag(tag: str) -> str:
139
+ """Rename tags."""
140
+ return _TAG_MAPPING.get(tag, tag)
141
+
142
+
143
+ def _remove_single_newline(text: str) -> str:
144
+ """Remove a single newline from repeated newlines. If the are just one newline, replace it with space."""
145
+ return re.sub(r"([\n]+)", lambda m: "\n" * (m.group(1).count("\n") - 1) or " ", text)