Skip to content

Commit

Permalink
Enhanced templating (#95)
Browse files Browse the repository at this point in the history
  • Loading branch information
lasuillard authored Oct 23, 2024
1 parent 0cc3de0 commit a3b03f8
Show file tree
Hide file tree
Showing 26 changed files with 738 additions and 146 deletions.
2 changes: 1 addition & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"cwd":"${workspaceFolder}/testproj",
"program":"manage.py",
"args": [
"runserver", "0.0.0.0:8000" // TODO: Determine bind address based on $CONTAINER
"runserver", "127.0.0.1:8000"
],
"django": true,
"justMyCode": true,
Expand Down
73 changes: 45 additions & 28 deletions django_slack_tools/slack_messages/backends/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,16 @@
from slack_sdk.errors import SlackApiError

from django_slack_tools.slack_messages.models import SlackMessage, SlackMessagingPolicy
from django_slack_tools.utils.dict_template import render
from django_slack_tools.utils.slack import MessageBody
from django_slack_tools.utils.template import DictTemplate, DjangoTemplate

if TYPE_CHECKING:
from slack_sdk.web import SlackResponse

from django_slack_tools.slack_messages.models.mention import SlackMention
from django_slack_tools.slack_messages.models.message_recipient import SlackMessageRecipient
from django_slack_tools.utils.slack import MessageHeader
from django_slack_tools.utils.template import BaseTemplate

logger = getLogger(__name__)

Expand All @@ -29,6 +30,32 @@
class BaseBackend(ABC):
"""Abstract base class for messaging backends."""

def prepare_message(
self,
*,
policy: SlackMessagingPolicy | None = None,
channel: str,
header: MessageHeader,
body: MessageBody,
) -> SlackMessage:
"""Prepare message.
Args:
policy: Related policy instance.
channel: Channel to send message.
header: Slack message control header.
body: Slack message body.
Returns:
Prepared message.
"""
_header: dict = policy.header_defaults if policy else {}
_header.update(header.model_dump(exclude_unset=True))

_body = body.model_dump()

return SlackMessage(policy=policy, channel=channel, header=_header, body=_body)

def prepare_messages_from_policy(
self,
policy: SlackMessagingPolicy,
Expand All @@ -54,18 +81,20 @@ def prepare_messages_from_policy(
", ".join(f"`{s}`" for s in overridden_reserved),
)

template = policy.template
messages: list[SlackMessage] = []
for recipient in policy.recipients.all():
logger.debug("Sending message to recipient %s", recipient)

# Initialize template instance
template = self._get_template_instance_from_policy(policy)

# Prepare rendering arguments
render_kwargs = self._get_default_context(policy=policy, recipient=recipient)
render_kwargs.update(context)
logger.debug("Context prepared as: %r", render_kwargs)
render_context = self._get_default_context(policy=policy, recipient=recipient)
render_context.update(context)
logger.debug("Context prepared as: %r", render_context)

# Render template and parse as body
rendered = render(template, **render_kwargs)
rendered = template.render(context=render_context)
body = MessageBody.model_validate(rendered)

# Create message instance
Expand All @@ -74,31 +103,19 @@ def prepare_messages_from_policy(

return SlackMessage.objects.bulk_create(messages)

def prepare_message(
self,
*,
policy: SlackMessagingPolicy | None = None,
channel: str,
header: MessageHeader,
body: MessageBody,
) -> SlackMessage:
"""Prepare message.
Args:
policy: Related policy instance.
channel: Channel to send message.
header: Slack message control header.
body: Slack message body.
def _get_template_instance_from_policy(self, policy: SlackMessagingPolicy) -> BaseTemplate:
"""Get template instance."""
if policy.template_type == SlackMessagingPolicy.TemplateType.Dict:
return DictTemplate(policy.template)

Returns:
Prepared message.
"""
_header: dict = policy.header_defaults if policy else {}
_header.update(header.model_dump(exclude_unset=True))
if policy.template_type == SlackMessagingPolicy.TemplateType.Django:
return DjangoTemplate(file=policy.template)

_body = body.model_dump()
if policy.template_type == SlackMessagingPolicy.TemplateType.DjangoInline:
return DjangoTemplate(inline=policy.template)

return SlackMessage(policy=policy, channel=channel, header=_header, body=_body)
msg = f"Unsupported template type: {policy.template_type!r}"
raise ValueError(msg)

def _get_default_context(self, *, policy: SlackMessagingPolicy, recipient: SlackMessageRecipient) -> dict[str, Any]:
"""Get default context for rendering.
Expand Down
2 changes: 0 additions & 2 deletions django_slack_tools/slack_messages/migrations/0001_initial.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import django.db.models.deletion
from django.db import migrations, models

import django_slack_tools.utils.dict_template
import django_slack_tools.utils.slack.django


Expand Down Expand Up @@ -163,7 +162,6 @@ class Migration(migrations.Migration):
blank=True,
help_text="Dictionary-based template object.",
null=True,
validators=[django_slack_tools.utils.dict_template.dict_template_validator],
verbose_name="Message template object",
),
),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 4.2.16 on 2024-10-19 05:07

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("slack_messages", "0003_default_recipient"),
]

operations = [
migrations.AddField(
model_name="slackmessagingpolicy",
name="template_type",
field=models.CharField(
choices=[("D", "Dictionary"), ("DJ", "Django"), ("DI", "Django Inline"), ("?", "Unknown")],
default="D",
help_text="Type of message template.",
max_length=2,
verbose_name="Template type",
),
),
]
31 changes: 28 additions & 3 deletions django_slack_tools/slack_messages/models/messaging_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,19 @@

from __future__ import annotations

from typing import TYPE_CHECKING

from django.db import models
from django.utils.translation import gettext_lazy as _

from django_slack_tools.utils.dict_template import dict_template_validator
from django_slack_tools.utils.model_mixins import TimestampMixin
from django_slack_tools.utils.slack import header_validator

from .message_recipient import SlackMessageRecipient

if TYPE_CHECKING:
from typing import Any


class SlackMessagingPolicyManager(models.Manager["SlackMessagingPolicy"]):
"""Manager for Slack messaging policies."""
Expand All @@ -19,6 +23,21 @@ class SlackMessagingPolicyManager(models.Manager["SlackMessagingPolicy"]):
class SlackMessagingPolicy(TimestampMixin, models.Model):
"""An Slack messaging policy which determines message content and those who receive messages."""

class TemplateType(models.TextChoices):
"""Possible template types."""

Dict = "D", _("Dictionary")
"Dictionary-based template."

Django = "DJ", _("Django")
"Django XML-based template."

DjangoInline = "DI", _("Django Inline")
"Django inline template."

UNKNOWN = "?", _("Unknown")
"Unknown template type."

code = models.CharField(
verbose_name=_("Code"),
help_text=_("Unique message code for lookup, mostly by human."),
Expand All @@ -42,10 +61,16 @@ class SlackMessagingPolicy(TimestampMixin, models.Model):
blank=True,
default=dict,
)
template: models.JSONField[dict | None] = models.JSONField(
template_type = models.CharField(
verbose_name=_("Template type"),
help_text=_("Type of message template."),
max_length=2,
choices=TemplateType.choices,
default=TemplateType.Dict,
)
template: models.JSONField[Any] = models.JSONField(
verbose_name=_("Message template object"),
help_text=_("Dictionary-based template object."),
validators=[dict_template_validator],
null=True,
blank=True,
)
Expand Down
55 changes: 0 additions & 55 deletions django_slack_tools/utils/dict_template.py

This file was deleted.

5 changes: 5 additions & 0 deletions django_slack_tools/utils/template/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .base import BaseTemplate
from .dict import DictTemplate
from .django import DjangoTemplate

__all__ = ("BaseTemplate", "DictTemplate", "DjangoTemplate")
17 changes: 17 additions & 0 deletions django_slack_tools/utils/template/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Abstraction for dictionary templates."""

from __future__ import annotations

from abc import ABC, abstractmethod
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from typing import Any


class BaseTemplate(ABC):
"""Abstract base class for dictionary templates."""

@abstractmethod
def render(self, *, context: dict[str, Any] | None = None) -> dict:
"""Render template with given context."""
52 changes: 52 additions & 0 deletions django_slack_tools/utils/template/dict.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# noqa: D100
from __future__ import annotations

from typing import TYPE_CHECKING, TypeVar

from .base import BaseTemplate

if TYPE_CHECKING:
from typing import Any


import logging

logger = logging.getLogger(__name__)


class DictTemplate(BaseTemplate):
"""Simple dictionary-based template."""

def __init__(self, template: dict) -> None:
"""Initialize template.
Args:
template: Dictionary template.
kwargs: Keyword arguments passed to template.
"""
self.template = template

def render(self, *, context: dict[str, Any] | None = None) -> dict: # noqa: D102
context = {} if context is None else context
result = self.template.copy()
for k, v in result.items():
result[k] = _format_obj(v, context=context)

return result


T = TypeVar("T", dict, list, str)


def _format_obj(obj: T, *, context: dict[str, Any]) -> T:
"""Format object recursively."""
if isinstance(obj, dict):
return {k: _format_obj(v, context=context) for k, v in obj.items()}

if isinstance(obj, str):
return obj.format_map(context)

if isinstance(obj, list):
return [_format_obj(item, context=context) for item in obj]

return obj
Loading

0 comments on commit a3b03f8

Please sign in to comment.