diff --git a/.github/workflows/runtests.yml b/.github/workflows/runtests.yml index 1802890..7057da0 100644 --- a/.github/workflows/runtests.yml +++ b/.github/workflows/runtests.yml @@ -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 }} diff --git a/docs/source/channels.md b/docs/source/channels.md index ad9b424..62fd4a2 100644 --- a/docs/source/channels.md +++ b/docs/source/channels.md @@ -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 @@ -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. - diff --git a/docs/source/conf.py b/docs/source/conf.py index 42a01c4..d7e863e 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -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 --------------------------------------------------- diff --git a/docs/source/template-tags.md b/docs/source/template-tags.md index 4519caa..87bae2d 100644 --- a/docs/source/template-tags.md +++ b/docs/source/template-tags.md @@ -94,4 +94,51 @@ This can help render `turbo-cable-stream-source` in Django template `` 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 `` 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 + +``` + +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. diff --git a/src/turbo_response/cable_channel.py b/src/turbo_response/cable_channel.py new file mode 100644 index 0000000..d89cb3f --- /dev/null +++ b/src/turbo_response/cable_channel.py @@ -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) diff --git a/src/turbo_response/channel_helper.py b/src/turbo_response/channel_helper.py index 88ace84..3096d70 100644 --- a/src/turbo_response/channel_helper.py +++ b/src/turbo_response/channel_helper.py @@ -1,4 +1,3 @@ -import json from typing import List, Tuple, Union from django.core import signing @@ -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 @@ -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, }, ) diff --git a/src/turbo_response/templatetags/turbo_helper.py b/src/turbo_response/templatetags/turbo_helper.py index bb5cbb6..9e6bb41 100644 --- a/src/turbo_response/templatetags/turbo_helper.py +++ b/src/turbo_response/templatetags/turbo_helper.py @@ -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 = [ @@ -124,9 +128,10 @@ def render(self, context): stream_name_string = stream_name_from(stream_name_array) django_engine = engines["django"] - template_string = """""" + template_string = """""" context = { "signed_stream_name": generate_signed_stream_key(stream_name_string), + "channel": TurboStreamCableChannel.__name__, } return django_engine.from_string(template_string).render(context) diff --git a/tests/conftest.py b/tests/conftest.py index 71d6a94..075152e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import os import pathlib import pytest @@ -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")], + }, + }, + }, ) diff --git a/tests/test_cable_channel.py b/tests/test_cable_channel.py new file mode 100644 index 0000000..4be3bef --- /dev/null +++ b/tests/test_cable_channel.py @@ -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() diff --git a/tests/test_tags.py b/tests/test_tags.py index 0bf28fa..63277d3 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -113,7 +113,7 @@ def test_string(self): output = render(template, {}).strip() assert ( output - == '' + == '' ) def test_dom_id_variable(self): @@ -125,5 +125,5 @@ def test_dom_id_variable(self): output = render(template, {"dom_id": "todo_3"}).strip() assert ( output - == '' + == '' ) diff --git a/tox.ini b/tox.ini index efba837..1b6e981 100644 --- a/tox.ini +++ b/tox.ini @@ -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