Skip to content

Commit

Permalink
Feature/17 (#19)
Browse files Browse the repository at this point in the history
* add turbo_stream_from
  • Loading branch information
michael-yin authored Nov 14, 2023
1 parent 0e49632 commit e299610
Show file tree
Hide file tree
Showing 11 changed files with 213 additions and 34 deletions.
7 changes: 7 additions & 0 deletions .github/workflows/runtests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,16 @@ on:
jobs:
runtests:
runs-on: ubuntu-latest
env:
CHANNELS_REDIS: redis://localhost:6379/0
strategy:
matrix:
python-version: ['3.8', '3.9', '3.10' ]
services:
redis:
image: redis
ports:
- 6379:6379
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
Expand Down
7 changes: 4 additions & 3 deletions docs/source/channels.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Django-Channels

```{warning}
This approach is not recommended anymore, please consider using [turbo_stream_from](./template-tags.md#turbo-stream-from) instead.
```

This library can also be used with [django-channels](https://channels.readthedocs.io/en/stable/). As with multiple streams, you can use the **TurboStream** class to broadcast turbo-stream content from your consumers.

```python
Expand Down Expand Up @@ -53,6 +57,3 @@ export default class extends Controller {
}
}
```

**Note** if you want to add reactivity directly to your models, so that model changes broadcast turbo-streams automatically, we recommend the [turbo-django](https://github.com/hotwire-django/turbo-django) package.

4 changes: 2 additions & 2 deletions docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
# -- Project information -----------------------------------------------------

project = "django-turbo-response"
copyright = f"{datetime.datetime.now().year}, Dan Jacob"
author = "Dan Jacob"
copyright = f"{datetime.datetime.now().year}, Michael Yin"
author = "Michael Yin"


# -- General configuration ---------------------------------------------------
Expand Down
49 changes: 48 additions & 1 deletion docs/source/template-tags.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,51 @@ This can help render `turbo-cable-stream-source` in Django template

`<turbo-cable-stream-source>` is a custom element provided by [turbo-rails](https://github.com/hotwired/turbo-rails/blob/097d8f90cf0c5ed24ac6b1a49cead73d49fa8ab5/app/javascript/turbo/cable_stream_source_element.js), with it, we can send Turbo Stream over the websocket connection and update the page in real time.

The `<turbo-cable-stream-source>` is built on Rails ActionCable, which provide many great feature out of the box, such as `Automatic Reconnection`, so we can focus on the business logic.
To import `turbo-cable-stream-source` element to the frontend, there are two ways:

```html
<script type="module">
import 'https://cdn.jsdelivr.net/npm/@hotwired/[email protected]/+esm'
</script>
```

Or you can [Jump start frontend project bundled by Webpack](https://github.com/AccordBox/python-webpack-boilerplate#jump-start-frontend-project-bundled-by-webpack) and install it via `npm install`

After frontend work is done, to support Actioncable on the server, please install [django-actioncable](https://github.com/AccordBox/django-actioncable).

In `routing.py`, register `TurboStreamCableChannel`

```python
from actioncable import cable_channel_register
from turbo_response.cable_channel import TurboStreamCableChannel

cable_channel_register(TurboStreamCableChannel)
```

In Django template, we can subscribe to stream source like this:

```html
{% load turbo_helper %}

{% turbo_stream_from 'chat' view.kwargs.chat_pk %}
```

`turbo_stream_from` can accept multiple positional arguments

Then in Python code, we can send Turbo Stream to the stream source like this

```python
from turbo_response.channel_helper import broadcast_render_to

broadcast_render_to(
["chat", instance.chat_id],
template="message_append.turbo_stream.html",
context={
"instance": instance,
},
)
```

The `["chat", instance.chat_id]` **should** match the positional arguments in the `turbo_stream_from` tag.

The web page can be updated in real time, through Turbo Stream over Websocket.
27 changes: 27 additions & 0 deletions src/turbo_response/cable_channel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from django.core.signing import Signer

from .channel_helper import verify_signed_stream_key

try:
from actioncable import ActionCableConsumer, CableChannel
except ImportError as err:
raise Exception("Please make sure django-actioncable is installed") from err

signer = Signer()


class TurboStreamCableChannel(CableChannel):
def __init__(self, consumer: ActionCableConsumer, identifier_key, params=None):
self.params = params if params else {}
self.identifier_key = identifier_key
self.consumer = consumer
self.group_name = None

async def subscribe(self):
flag, stream_name = verify_signed_stream_key(self.params["signed_stream_name"])
self.group_name = stream_name
if flag:
await self.consumer.subscribe_group(self.group_name, self)

async def unsubscribe(self):
await self.consumer.unsubscribe_group(self.group_name, self)
44 changes: 19 additions & 25 deletions src/turbo_response/channel_helper.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import json
from typing import List, Tuple, Union

from django.core import signing
Expand All @@ -8,8 +7,7 @@
from .templatetags.turbo_helper import dom_id

try:
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from actioncable import cable_broadcast
except ImportError as err:
raise Exception("Please make sure django-channels is installed") from err

Expand Down Expand Up @@ -48,36 +46,32 @@ def verify_signed_stream_key(signed_stream_key: str) -> Tuple[bool, str]:
return False, ""


def generate_channel_group_name(channel: str, stream_name: str):
"""
Generate Django Channel group name from channel and stream_name
def broadcast_render_to(streamables: Union[List, object], template: str, context=None):
"""
return f"{channel}_{stream_name}"
This function help render HTML to Turbo Stream Channel
for example, in Django template, we subscribe to a Turbo stream Channel
def broadcast_render_to(streamables: Union[List, object], template: str, context=None):
{% turbo_stream_from 'chat' view.kwargs.chat_pk %}
Then in Python code
broadcast_render_to(
["chat", instance.chat_id],
template="message_append.turbo_stream.html",
context={
"instance": instance,
},
)
"""
if context is None:
context = {}

html = render_to_string(template, context=context)

stream_name = stream_name_from(streamables)
channel_group_name = generate_channel_group_name("TurboStreamsChannel", stream_name)

channel_layer = get_channel_layer()
async_to_sync(channel_layer.group_send)(
channel_group_name,
cable_broadcast(
stream_name,
{
"type": "turbo_stream_message",
"data": {
"identifier": json.dumps(
{
"channel": "TurboStreamsChannel",
"signed_stream_name": generate_signed_stream_key(stream_name),
},
separators=(",", ":"),
),
"message": html,
},
"message": html,
},
)
7 changes: 6 additions & 1 deletion src/turbo_response/templatetags/turbo_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,16 @@ def render(self, context):

class TurboStreamFromTagNode(Node):
def __init__(self, stream_name_array):
"""
TODO: Support override channel
"""
self.stream_name_array = stream_name_array

def __repr__(self):
return "<%s>" % self.__class__.__name__

def render(self, context):
from ..cable_channel import TurboStreamCableChannel
from ..channel_helper import generate_signed_stream_key, stream_name_from

stream_name_array = [
Expand All @@ -124,9 +128,10 @@ def render(self, context):
stream_name_string = stream_name_from(stream_name_array)

django_engine = engines["django"]
template_string = """<turbo-cable-stream-source channel="TurboStreamsChannel" signed-stream-name="{{ signed_stream_name }}"></turbo-cable-stream-source>"""
template_string = """<turbo-cable-stream-source channel="{{ channel }}" signed-stream-name="{{ signed_stream_name }}"></turbo-cable-stream-source>"""
context = {
"signed_stream_name": generate_signed_stream_key(stream_name_string),
"channel": TurboStreamCableChannel.__name__,
}
return django_engine.from_string(template_string).render(context)

Expand Down
9 changes: 9 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
import pathlib

import pytest
Expand Down Expand Up @@ -35,6 +36,14 @@ def pytest_configure():
"tests.testapp.apps.TestAppConfig",
],
ROOT_URLCONF="tests.testapp.urls",
CHANNEL_LAYERS={
"default": {
"BACKEND": "channels_redis.pubsub.RedisPubSubChannelLayer",
"CONFIG": {
"hosts": [os.getenv("CHANNEL_LAYERS", "redis://localhost:6379/0")],
},
},
},
)


Expand Down
85 changes: 85 additions & 0 deletions tests/test_cable_channel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import pytest
from actioncable import ActionCableConsumer, cable_channel_register, compact_encode_json
from channels.layers import get_channel_layer
from channels.testing import WebsocketCommunicator

from turbo_response.cable_channel import TurboStreamCableChannel
from turbo_response.channel_helper import generate_signed_stream_key

# register the TurboStreamCableChannel
cable_channel_register(TurboStreamCableChannel)


@pytest.mark.asyncio
async def test_subscribe():
communicator = WebsocketCommunicator(
ActionCableConsumer.as_asgi(), "/cable", subprotocols=["actioncable-v1-json"]
)
connected, subprotocol = await communicator.connect(timeout=10)
assert connected
response = await communicator.receive_json_from()
assert response == {"type": "welcome"}

# Subscribe
group_name = "test"
subscribe_command = {
"command": "subscribe",
"identifier": compact_encode_json(
{
"channel": TurboStreamCableChannel.__name__,
"signed_stream_name": generate_signed_stream_key(group_name),
}
),
}

await communicator.send_to(text_data=compact_encode_json(subscribe_command))
response = await communicator.receive_json_from(timeout=10)
assert response["type"] == "confirm_subscription"

# Message
channel_layer = get_channel_layer()
await channel_layer.group_send(
group_name,
{
"type": "message",
"group": group_name,
"data": {
"message": "html_snippet",
},
},
)

response = await communicator.receive_json_from(timeout=5)
assert response["message"] == "html_snippet"

# Unsubscribe
group_name = "test"
subscribe_command = {
"command": "unsubscribe",
"identifier": compact_encode_json(
{
"channel": TurboStreamCableChannel.__name__,
"signed_stream_name": generate_signed_stream_key(group_name),
}
),
}

await communicator.send_to(text_data=compact_encode_json(subscribe_command))

# Message
channel_layer = get_channel_layer()
await channel_layer.group_send(
group_name,
{
"type": "message",
"group": group_name,
"data": {
"message": "html_snippet",
},
},
)

assert await communicator.receive_nothing() is True

# Close
await communicator.disconnect()
4 changes: 2 additions & 2 deletions tests/test_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ def test_string(self):
output = render(template, {}).strip()
assert (
output
== '<turbo-cable-stream-source channel="TurboStreamsChannel" signed-stream-name="test:1DyYXz2Y_VbgIPXC1AQ0ZGHhAx71uaZ36r4DFwXDaiU"></turbo-cable-stream-source>'
== '<turbo-cable-stream-source channel="TurboStreamCableChannel" signed-stream-name="test:1DyYXz2Y_VbgIPXC1AQ0ZGHhAx71uaZ36r4DFwXDaiU"></turbo-cable-stream-source>'
)

def test_dom_id_variable(self):
Expand All @@ -125,5 +125,5 @@ def test_dom_id_variable(self):
output = render(template, {"dom_id": "todo_3"}).strip()
assert (
output
== '<turbo-cable-stream-source channel="TurboStreamsChannel" signed-stream-name="test_todo_3:7ZS0MxQWhRTCAnG3olGO9AJKfvos3iaHGoBMBt8ZbSM"></turbo-cable-stream-source>'
== '<turbo-cable-stream-source channel="TurboStreamCableChannel" signed-stream-name="test_todo_3:7ZS0MxQWhRTCAnG3olGO9AJKfvos3iaHGoBMBt8ZbSM"></turbo-cable-stream-source>'
)
4 changes: 4 additions & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ deps =
django32: django>=3.2,<3.3
django42: django>=3.3,<4.3
channels
daphne
pytest-asyncio
channels_redis
django-actioncable
typing_extensions
pytest
pytest-django
Expand Down

0 comments on commit e299610

Please sign in to comment.