django-slack-tools 0.1.0__py3-none-any.whl → 0.2.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 (28) hide show
  1. django_slack_tools/__init__.py +1 -1
  2. django_slack_tools/app_settings.py +29 -4
  3. django_slack_tools/slack_messages/admin/message.py +1 -1
  4. django_slack_tools/slack_messages/apps.py +9 -0
  5. django_slack_tools/slack_messages/backends/__init__.py +2 -2
  6. django_slack_tools/slack_messages/backends/base.py +182 -47
  7. django_slack_tools/slack_messages/backends/dummy.py +8 -9
  8. django_slack_tools/slack_messages/backends/slack.py +29 -91
  9. django_slack_tools/slack_messages/message.py +40 -58
  10. django_slack_tools/slack_messages/migrations/0001_initial.py +0 -2
  11. django_slack_tools/slack_messages/migrations/0002_default_policy.py +35 -0
  12. django_slack_tools/slack_messages/migrations/0003_default_recipient.py +35 -0
  13. django_slack_tools/slack_messages/migrations/0004_slackmessagingpolicy_template_type.py +23 -0
  14. django_slack_tools/slack_messages/models/messaging_policy.py +29 -3
  15. django_slack_tools/slack_messages/tasks.py +112 -0
  16. django_slack_tools/utils/slack/django.py +8 -24
  17. django_slack_tools/utils/slack/message.py +74 -25
  18. django_slack_tools/utils/template/__init__.py +5 -0
  19. django_slack_tools/utils/template/base.py +17 -0
  20. django_slack_tools/utils/template/dict.py +52 -0
  21. django_slack_tools/utils/template/django.py +140 -0
  22. django_slack_tools/views.py +1 -1
  23. {django_slack_tools-0.1.0.dist-info → django_slack_tools-0.2.0.dist-info}/METADATA +39 -21
  24. django_slack_tools-0.2.0.dist-info/RECORD +43 -0
  25. {django_slack_tools-0.1.0.dist-info → django_slack_tools-0.2.0.dist-info}/WHEEL +1 -1
  26. django_slack_tools/utils/dict_template.py +0 -55
  27. django_slack_tools-0.1.0.dist-info/RECORD +0 -36
  28. {django_slack_tools-0.1.0.dist-info → django_slack_tools-0.2.0.dist-info/licenses}/LICENSE +0 -0
@@ -2,29 +2,32 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from logging import getLogger
5
+ import logging
6
6
  from typing import TYPE_CHECKING, Any
7
7
 
8
8
  from django_slack_tools.app_settings import app_settings
9
- from django_slack_tools.utils.dict_template import render
9
+ from django_slack_tools.slack_messages.models.message_recipient import SlackMessageRecipient
10
10
  from django_slack_tools.utils.slack import MessageBody, MessageHeader
11
11
 
12
12
  from .models import SlackMessagingPolicy
13
13
 
14
- logger = getLogger(__name__)
14
+ logger = logging.getLogger(__name__)
15
15
 
16
16
  if TYPE_CHECKING:
17
- from .models import SlackMention, SlackMessage
17
+ from django_slack_tools.slack_messages.backends.base import BaseBackend
18
18
 
19
+ from .models import SlackMessage
19
20
 
20
- def slack_message(
21
+
22
+ def slack_message( # noqa: PLR0913
21
23
  body: str | MessageBody | dict[str, Any],
22
24
  *,
23
25
  channel: str,
24
26
  header: MessageHeader | dict[str, Any] | None = None,
25
27
  raise_exception: bool = False,
26
28
  get_permalink: bool = False,
27
- ) -> SlackMessage | None:
29
+ backend: BaseBackend = app_settings.backend,
30
+ ) -> SlackMessage:
28
31
  """Send a simple text message.
29
32
 
30
33
  Args:
@@ -33,96 +36,75 @@ def slack_message(
33
36
  header: Slack message control header.
34
37
  raise_exception: Whether to re-raise caught exception while sending messages.
35
38
  get_permalink: Try to get the message permalink via extraneous Slack API calls.
39
+ backend: Messaging backend. If not set, use `app_settings.backend`.
36
40
 
37
41
  Returns:
38
42
  Sent message instance or `None`.
39
43
  """
40
- # If body is just an string, make a simple message body
41
- body = MessageBody(text=body) if isinstance(body, str) else MessageBody.model_validate(body)
42
- header = MessageHeader.model_validate(header or {})
44
+ body = MessageBody.from_any(body)
45
+ header = MessageHeader.from_any(header)
46
+ message = backend.prepare_message(channel=channel, header=header, body=body)
43
47
 
44
- return app_settings.backend.send_message(
45
- channel=channel,
46
- header=header,
47
- body=body,
48
- raise_exception=raise_exception,
49
- get_permalink=get_permalink,
50
- )
48
+ return backend.send_message(message, raise_exception=raise_exception, get_permalink=get_permalink)
51
49
 
52
50
 
53
51
  def slack_message_via_policy( # noqa: PLR0913
54
- policy: str | SlackMessagingPolicy,
52
+ policy: str | SlackMessagingPolicy = app_settings.default_policy_code,
55
53
  *,
56
54
  header: MessageHeader | dict[str, Any] | None = None,
57
55
  raise_exception: bool = False,
58
56
  lazy: bool = False,
59
57
  get_permalink: bool = False,
60
58
  context: dict[str, Any] | None = None,
61
- ) -> list[SlackMessage | None]:
59
+ backend: BaseBackend = app_settings.backend,
60
+ ) -> int:
62
61
  """Send a simple text message.
63
62
 
64
- Mentions for each recipient will be passed to template as keyword `{mentions}`.
65
- Template should include it to use mentions.
63
+ Some default context variables are populated and available for use in templates.
64
+ See corresponding backend implementation for available context variables.
66
65
 
67
66
  Args:
68
- policy: Messaging policy code or policy instance.
67
+ policy: Messaging policy code or policy instance. Defaults to app's default policy.
69
68
  header: Slack message control header.
70
69
  raise_exception: Whether to re-raise caught exception while sending messages.
71
70
  lazy: Decide whether try create policy with disabled, if not exists.
72
71
  get_permalink: Try to get the message permalink via extraneous Slack API calls.
73
72
  context: Dictionary to pass to template for rendering.
73
+ backend: Messaging backend. If not set, use `app_settings.backend`.
74
74
 
75
75
  Returns:
76
- Sent message instance or `None`.
76
+ Count of messages sent successfully.
77
77
 
78
78
  Raises:
79
79
  SlackMessagingPolicy.DoesNotExist: Policy for given code does not exists.
80
80
  """
81
81
  if isinstance(policy, str):
82
82
  if lazy:
83
- policy, created = SlackMessagingPolicy.objects.get_or_create(code=policy, defaults={"enabled": False})
83
+ policy, created = SlackMessagingPolicy.objects.get_or_create(
84
+ code=policy,
85
+ defaults={
86
+ "enabled": app_settings.lazy_policy_enabled,
87
+ "template": app_settings.default_template,
88
+ },
89
+ )
84
90
  if created:
91
+ # Add default recipient for created policy
92
+ recipient = SlackMessageRecipient.objects.get(alias=app_settings.default_recipient)
93
+ policy.recipients.add(recipient)
85
94
  logger.warning("Policy for code %r created because `lazy` is set.", policy)
86
95
  else:
87
96
  policy = SlackMessagingPolicy.objects.get(code=policy)
88
97
 
89
- if not policy.enabled:
90
- return []
91
-
92
- header = MessageHeader.model_validate(header or {})
98
+ header = MessageHeader.from_any(header)
93
99
  context = context or {}
94
100
 
95
- # Prepare template
96
- template = policy.template
97
- overridden_reserved = {"mentions", "mentions_as_str"} & set(context.keys())
98
- if overridden_reserved:
101
+ messages = backend.prepare_messages_from_policy(policy, header=header, context=context)
102
+ if not policy.enabled:
99
103
  logger.warning(
100
- "Template keyword argument(s) %s reserved for passing mentions, but already exists."
101
- " User provided value will override it.",
102
- ", ".join(f"`{s}`" for s in overridden_reserved),
103
- )
104
-
105
- messages: list[SlackMessage | None] = []
106
- for recipient in policy.recipients.all():
107
- # Auto-generated reserved kwargs
108
- mentions: list[SlackMention] = list(recipient.mentions.all())
109
- mentions_as_str = ", ".join(mention.mention for mention in mentions)
110
-
111
- # Prepare rendering arguments
112
- kwargs = {"mentions": mentions, "mentions_as_str": mentions_as_str}
113
- kwargs.update(context)
114
-
115
- # Render and send message
116
- rendered = render(template, **kwargs)
117
- body = MessageBody.model_validate(rendered)
118
- message = app_settings.backend.send_message(
119
- policy=policy,
120
- channel=recipient.channel,
121
- header=header,
122
- body=body,
123
- raise_exception=raise_exception,
124
- get_permalink=get_permalink,
104
+ "Created %d messages but not sending because policy %s is not enabled.",
105
+ len(messages),
106
+ policy.code,
125
107
  )
126
- messages.append(message)
108
+ return 0
127
109
 
128
- return messages
110
+ return backend.send_messages(*messages, raise_exception=raise_exception, get_permalink=get_permalink)
@@ -3,7 +3,6 @@
3
3
  import django.db.models.deletion
4
4
  from django.db import migrations, models
5
5
 
6
- import django_slack_tools.utils.dict_template
7
6
  import django_slack_tools.utils.slack.django
8
7
 
9
8
 
@@ -163,7 +162,6 @@ class Migration(migrations.Migration):
163
162
  blank=True,
164
163
  help_text="Dictionary-based template object.",
165
164
  null=True,
166
- validators=[django_slack_tools.utils.dict_template.dict_template_validator],
167
165
  verbose_name="Message template object",
168
166
  ),
169
167
  ),
@@ -0,0 +1,35 @@
1
+ # Generated by Django 4.2.11 on 2024-04-09 13:39
2
+ from __future__ import annotations
3
+
4
+ from typing import TYPE_CHECKING
5
+
6
+ from django.db import migrations
7
+
8
+ if TYPE_CHECKING:
9
+ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
10
+ from django.db.migrations.state import StateApps
11
+
12
+
13
+ def _create_default_policy(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor) -> None:
14
+ db_alias = schema_editor.connection.alias
15
+ model_type = apps.get_model("slack_messages", "SlackMessagingPolicy")
16
+ model_type.objects.using(db_alias).create(code="DEFAULT", enabled=False)
17
+
18
+
19
+ def _delete_default_policy(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor) -> None:
20
+ db_alias = schema_editor.connection.alias
21
+ model_type = apps.get_model("slack_messages", "SlackMessagingPolicy")
22
+ model_type.objects.using(db_alias).filter(code="DEFAULT").delete()
23
+
24
+
25
+ class Migration(migrations.Migration):
26
+ dependencies = [
27
+ ("slack_messages", "0001_initial"),
28
+ ]
29
+
30
+ operations = [
31
+ migrations.RunPython(
32
+ code=_create_default_policy,
33
+ reverse_code=_delete_default_policy,
34
+ ),
35
+ ]
@@ -0,0 +1,35 @@
1
+ # Generated by Django 4.2.11 on 2024-04-09 13:39
2
+ from __future__ import annotations
3
+
4
+ from typing import TYPE_CHECKING
5
+
6
+ from django.db import migrations
7
+
8
+ if TYPE_CHECKING:
9
+ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
10
+ from django.db.migrations.state import StateApps
11
+
12
+
13
+ def _create_default_recipient(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor) -> None:
14
+ db_alias = schema_editor.connection.alias
15
+ model_type = apps.get_model("slack_messages", "SlackMessageRecipient")
16
+ model_type.objects.using(db_alias).create(alias="DEFAULT")
17
+
18
+
19
+ def _delete_default_recipient(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor) -> None:
20
+ db_alias = schema_editor.connection.alias
21
+ model_type = apps.get_model("slack_messages", "SlackMessageRecipient")
22
+ model_type.objects.using(db_alias).filter(alias="DEFAULT").delete()
23
+
24
+
25
+ class Migration(migrations.Migration):
26
+ dependencies = [
27
+ ("slack_messages", "0002_default_policy"),
28
+ ]
29
+
30
+ operations = [
31
+ migrations.RunPython(
32
+ code=_create_default_recipient,
33
+ reverse_code=_delete_default_recipient,
34
+ ),
35
+ ]
@@ -0,0 +1,23 @@
1
+ # Generated by Django 4.2.16 on 2024-10-19 05:07
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+ dependencies = [
8
+ ("slack_messages", "0003_default_recipient"),
9
+ ]
10
+
11
+ operations = [
12
+ migrations.AddField(
13
+ model_name="slackmessagingpolicy",
14
+ name="template_type",
15
+ field=models.CharField(
16
+ choices=[("D", "Dictionary"), ("DJ", "Django"), ("DI", "Django Inline"), ("?", "Unknown")],
17
+ default="D",
18
+ help_text="Type of message template.",
19
+ max_length=2,
20
+ verbose_name="Template type",
21
+ ),
22
+ ),
23
+ ]
@@ -2,15 +2,19 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from typing import TYPE_CHECKING
6
+
5
7
  from django.db import models
6
8
  from django.utils.translation import gettext_lazy as _
7
9
 
8
- from django_slack_tools.utils.dict_template import dict_template_validator
9
10
  from django_slack_tools.utils.model_mixins import TimestampMixin
10
11
  from django_slack_tools.utils.slack import header_validator
11
12
 
12
13
  from .message_recipient import SlackMessageRecipient
13
14
 
15
+ if TYPE_CHECKING:
16
+ from typing import Any
17
+
14
18
 
15
19
  class SlackMessagingPolicyManager(models.Manager["SlackMessagingPolicy"]):
16
20
  """Manager for Slack messaging policies."""
@@ -19,6 +23,21 @@ class SlackMessagingPolicyManager(models.Manager["SlackMessagingPolicy"]):
19
23
  class SlackMessagingPolicy(TimestampMixin, models.Model):
20
24
  """An Slack messaging policy which determines message content and those who receive messages."""
21
25
 
26
+ class TemplateType(models.TextChoices):
27
+ """Possible template types."""
28
+
29
+ DICT = "D", _("Dictionary")
30
+ "Dictionary-based template."
31
+
32
+ DJANGO = "DJ", _("Django")
33
+ "Django XML-based template."
34
+
35
+ DJANGO_INLINE = "DI", _("Django Inline")
36
+ "Django inline template."
37
+
38
+ UNKNOWN = "?", _("Unknown")
39
+ "Unknown template type."
40
+
22
41
  code = models.CharField(
23
42
  verbose_name=_("Code"),
24
43
  help_text=_("Unique message code for lookup, mostly by human."),
@@ -42,13 +61,20 @@ class SlackMessagingPolicy(TimestampMixin, models.Model):
42
61
  blank=True,
43
62
  default=dict,
44
63
  )
45
- template = models.JSONField(
64
+ template_type = models.CharField(
65
+ verbose_name=_("Template type"),
66
+ help_text=_("Type of message template."),
67
+ max_length=2,
68
+ choices=TemplateType.choices,
69
+ default=TemplateType.DICT,
70
+ )
71
+ template: models.JSONField[Any] = models.JSONField(
46
72
  verbose_name=_("Message template object"),
47
73
  help_text=_("Dictionary-based template object."),
48
- validators=[dict_template_validator],
49
74
  null=True,
50
75
  blank=True,
51
76
  )
77
+
52
78
  # Type is too obvious but due to limits...
53
79
  objects: SlackMessagingPolicyManager = SlackMessagingPolicyManager()
54
80
 
@@ -0,0 +1,112 @@
1
+ """Celery utils."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timedelta
6
+ from typing import TYPE_CHECKING
7
+
8
+ from celery import shared_task
9
+ from celery.utils.log import get_task_logger
10
+ from django.utils import timezone
11
+
12
+ from django_slack_tools.slack_messages import message
13
+ from django_slack_tools.slack_messages.models import SlackMessage
14
+
15
+ if TYPE_CHECKING:
16
+ from typing import Any
17
+
18
+ logger = get_task_logger(__name__)
19
+
20
+
21
+ @shared_task
22
+ def slack_message(
23
+ body: str | dict[str, Any],
24
+ *,
25
+ channel: str,
26
+ header: dict[str, Any] | None = None,
27
+ raise_exception: bool = False,
28
+ get_permalink: bool = False,
29
+ ) -> int | None:
30
+ """Celery task wrapper for `message.slack_message`.
31
+
32
+ Args:
33
+ body: Message content, simple message or full request body.
34
+ channel: Channel to send message.
35
+ header: Slack message control header.
36
+ raise_exception: Whether to re-raise caught exception while sending messages.
37
+ get_permalink: Try to get the message permalink via extraneous Slack API calls.
38
+
39
+ Returns:
40
+ ID of sent message.
41
+ """
42
+ sent_msg = message.slack_message(
43
+ body,
44
+ channel=channel,
45
+ header=header,
46
+ raise_exception=raise_exception,
47
+ get_permalink=get_permalink,
48
+ )
49
+ return sent_msg.id
50
+
51
+
52
+ @shared_task
53
+ def slack_message_via_policy( # noqa: PLR0913
54
+ policy: str,
55
+ *,
56
+ header: dict[str, Any] | None = None,
57
+ raise_exception: bool = False,
58
+ lazy: bool = False,
59
+ get_permalink: bool = False,
60
+ context: dict[str, Any] | None = None,
61
+ ) -> int:
62
+ """Celery task wrapper for `message.slack_message_via_policy`.
63
+
64
+ Args:
65
+ policy: Messaging policy code.
66
+ header: Slack message control header.
67
+ raise_exception: Whether to re-raise caught exception while sending messages.
68
+ lazy: Decide whether try to create policy with disabled, if not exists.
69
+ get_permalink: Try to get the message permalink via extraneous Slack API calls.
70
+ context: Context variables for message rendering.
71
+
72
+ Returns:
73
+ Number of sent messages.
74
+ """
75
+ return message.slack_message_via_policy(
76
+ policy,
77
+ header=header,
78
+ raise_exception=raise_exception,
79
+ lazy=lazy,
80
+ get_permalink=get_permalink,
81
+ context=context,
82
+ )
83
+
84
+
85
+ @shared_task
86
+ def cleanup_old_messages(
87
+ *,
88
+ base_ts: str | None = None,
89
+ threshold_seconds: int | None = 7 * 24 * 60 * 60, # 7 days
90
+ ) -> int:
91
+ """Delete old messages created before given threshold.
92
+
93
+ Args:
94
+ threshold_seconds: Threshold seconds. Defaults to 7 days.
95
+ base_ts: Base timestamp to calculate the threshold, in ISO format. If falsy, current timestamp will be used.
96
+
97
+ Returns:
98
+ Number of deleted messages.
99
+ """
100
+ dt = datetime.fromisoformat(base_ts) if base_ts else timezone.localtime()
101
+
102
+ if threshold_seconds is None:
103
+ logger.warning("Threshold seconds not provided, skipping cleanup.")
104
+ return 0
105
+
106
+ cleanup_threshold = dt - timedelta(seconds=threshold_seconds)
107
+ logger.debug("Cleaning up messages older than %s.", cleanup_threshold)
108
+
109
+ num_deleted, _ = SlackMessage.objects.filter(created__lt=cleanup_threshold).delete()
110
+ logger.info("Deleted %d old messages.", num_deleted)
111
+
112
+ return num_deleted
@@ -2,44 +2,28 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Any
6
-
7
- import pydantic
8
5
  from django.core.exceptions import ValidationError
9
- from django.utils.translation import gettext_lazy as _
10
6
 
11
7
  from .message import MessageBody, MessageHeader
12
8
 
13
9
 
14
- def header_validator(value: Any) -> None:
10
+ def header_validator(d: dict) -> None:
15
11
  """Validate given value is valid message header."""
16
12
  try:
17
- MessageHeader.model_validate(value)
18
- except pydantic.ValidationError as exc:
13
+ MessageHeader.from_any(d)
14
+ except Exception as exc:
19
15
  err = _convert_errors(exc)
20
16
  raise err from exc
21
17
 
22
18
 
23
- def body_validator(value: Any) -> None:
19
+ def body_validator(d: dict) -> None:
24
20
  """Validate given value is valid message body."""
25
21
  try:
26
- MessageBody.model_validate(value)
27
- except pydantic.ValidationError as exc:
22
+ MessageBody.from_any(d)
23
+ except Exception as exc:
28
24
  err = _convert_errors(exc)
29
25
  raise err from exc
30
26
 
31
27
 
32
- def _convert_errors(exc: pydantic.ValidationError) -> ValidationError:
33
- """Convert Pydantic validation error to Django error."""
34
- errors = [
35
- ValidationError(
36
- _("Input validation failed [msg=%(msg)r, input=%(input)r]"),
37
- code=", ".join(map(str, err["loc"])),
38
- params={
39
- "msg": err["msg"],
40
- "input": err["input"],
41
- },
42
- )
43
- for err in exc.errors()
44
- ]
45
- return ValidationError(errors)
28
+ def _convert_errors(exc: Exception) -> ValidationError:
29
+ return ValidationError(str(exc))
@@ -2,43 +2,92 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import List, Optional
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING
6
7
 
7
- from pydantic import BaseModel, model_validator
8
+ if TYPE_CHECKING:
9
+ from typing import Any, List, Optional
8
10
 
11
+ NoneType = type(None)
9
12
 
10
- class MessageHeader(BaseModel):
11
- """Type definition for message header."""
12
13
 
13
- # NOTE: `channel` is omitted to handle it in recipients
14
- # Because extra fields not forbidden for reasons, channel can be passed but not recommended
14
+ @dataclass
15
+ class MessageHeader:
16
+ """Message header data definition."""
15
17
 
16
- mrkdwn: Optional[str] = None # noqa: UP007
17
- parse: Optional[str] = None # noqa: UP007
18
- reply_broadcast: Optional[bool] = None # noqa: UP007
19
- thread_ts: Optional[str] = None # noqa: UP007
20
- unfurl_links: Optional[bool] = None # noqa: UP007
21
- unfurl_media: Optional[bool] = None # noqa: UP007
18
+ mrkdwn: Optional[str] = field(default=None) # noqa: UP007
19
+ parse: Optional[str] = field(default=None) # noqa: UP007
20
+ reply_broadcast: Optional[bool] = field(default=None) # noqa: UP007
21
+ thread_ts: Optional[str] = field(default=None) # noqa: UP007
22
+ unfurl_links: Optional[bool] = field(default=None) # noqa: UP007
23
+ unfurl_media: Optional[bool] = field(default=None) # noqa: UP007
22
24
 
25
+ def __post_init__(self) -> None:
26
+ _assert_type(self.mrkdwn, (str, NoneType))
27
+ _assert_type(self.parse, (str, NoneType))
28
+ _assert_type(self.reply_broadcast, (bool, NoneType))
29
+ _assert_type(self.thread_ts, (str, NoneType))
30
+ _assert_type(self.unfurl_links, (bool, NoneType))
31
+ _assert_type(self.unfurl_media, (bool, NoneType))
23
32
 
24
- class MessageBody(BaseModel):
25
- """Type definition for message body."""
33
+ @classmethod
34
+ def from_any(
35
+ cls,
36
+ obj: MessageHeader | dict[str, Any] | None = None,
37
+ ) -> MessageHeader:
38
+ """Create instance from compatible types."""
39
+ if obj is None:
40
+ return cls()
26
41
 
27
- attachments: Optional[List[dict]] = None # noqa: UP006, UP007
42
+ if isinstance(obj, dict):
43
+ return cls(**obj)
44
+
45
+ msg = f"Unsupported type {type(obj)}"
46
+ raise TypeError(msg)
47
+
48
+
49
+ @dataclass
50
+ class MessageBody:
51
+ """Data definition for message body."""
52
+
53
+ attachments: Optional[List[dict]] = field(default=None) # noqa: UP006, UP007
28
54
 
29
55
  # See more about blocks at https://api.slack.com/reference/block-kit/blocks
30
- blocks: Optional[List[dict]] = None # noqa: UP006, UP007
56
+ blocks: Optional[List[dict]] = field(default=None) # noqa: UP006, UP007
57
+
58
+ text: Optional[str] = field(default=None) # noqa: UP007
59
+ icon_emoji: Optional[str] = field(default=None) # noqa: UP007
60
+ icon_url: Optional[str] = field(default=None) # noqa: UP007
61
+ metadata: Optional[dict] = field(default=None) # noqa: UP007
62
+ username: Optional[str] = field(default=None) # noqa: UP007
31
63
 
32
- text: Optional[str] = None # noqa: UP007
33
- icon_emoji: Optional[str] = None # noqa: UP007
34
- icon_url: Optional[str] = None # noqa: UP007
35
- metadata: Optional[dict] = None # noqa: UP007
36
- username: Optional[str] = None # noqa: UP007
64
+ def __post_init__(self) -> None:
65
+ _assert_type(self.attachments, (list, NoneType))
66
+ _assert_type(self.blocks, (list, NoneType))
67
+ _assert_type(self.text, (str, NoneType))
68
+ _assert_type(self.icon_emoji, (str, NoneType))
69
+ _assert_type(self.icon_url, (str, NoneType))
70
+ _assert_type(self.metadata, (dict, NoneType))
71
+ _assert_type(self.username, (str, NoneType))
37
72
 
38
- @model_validator(mode="after")
39
- def _check_one_of_exists(self) -> MessageBody:
40
- if not self.attachments and not self.blocks and not self.text:
73
+ if not any((self.attachments, self.blocks, self.text)):
41
74
  msg = "At least one of `attachments`, `blocks` and `text` must set"
42
75
  raise ValueError(msg)
43
76
 
44
- return self
77
+ @classmethod
78
+ def from_any(cls, obj: str | MessageBody | dict[str, Any]) -> MessageBody:
79
+ """Create instance from compatible types."""
80
+ if isinstance(obj, dict):
81
+ return cls(**obj)
82
+
83
+ if isinstance(obj, str):
84
+ return cls(text=obj)
85
+
86
+ msg = f"Unsupported type {type(obj)}"
87
+ raise TypeError(msg)
88
+
89
+
90
+ def _assert_type(obj: Any, cls: type | tuple[type, ...]) -> None:
91
+ if not isinstance(obj, cls):
92
+ msg = f"Invalid value type, expected {cls}, got {type(obj)}"
93
+ raise TypeError(msg)
@@ -0,0 +1,5 @@
1
+ from .base import BaseTemplate
2
+ from .dict import DictTemplate
3
+ from .django import DjangoTemplate
4
+
5
+ __all__ = ("BaseTemplate", "DictTemplate", "DjangoTemplate")
@@ -0,0 +1,17 @@
1
+ """Abstraction for dictionary templates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from typing import Any
10
+
11
+
12
+ class BaseTemplate(ABC):
13
+ """Abstract base class for dictionary templates."""
14
+
15
+ @abstractmethod
16
+ def render(self, *, context: dict[str, Any] | None = None) -> dict:
17
+ """Render template with given context."""