From 0e49632f644e669d5fab61d9f85d375764650fd4 Mon Sep 17 00:00:00 2001 From: Michael Yin Date: Thu, 9 Nov 2023 11:43:31 +0800 Subject: [PATCH] add turbo_stream_from (#18) --- docs/source/template-tags.md | 8 ++ src/turbo_response/channel_helper.py | 83 +++++++++++++++++++ .../templatetags/turbo_helper.py | 38 +++++++++ tests/conftest.py | 2 +- tests/test_tags.py | 26 ++++++ tox.ini | 1 + 6 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 src/turbo_response/channel_helper.py diff --git a/docs/source/template-tags.md b/docs/source/template-tags.md index 6202d7e..4519caa 100644 --- a/docs/source/template-tags.md +++ b/docs/source/template-tags.md @@ -87,3 +87,11 @@ return TurboStreamResponse(html) ``` The code in Django view would be much cleaner and easier to maintain. + +## turbo_stream_from + +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. diff --git a/src/turbo_response/channel_helper.py b/src/turbo_response/channel_helper.py new file mode 100644 index 0000000..88ace84 --- /dev/null +++ b/src/turbo_response/channel_helper.py @@ -0,0 +1,83 @@ +import json +from typing import List, Tuple, Union + +from django.core import signing +from django.core.signing import Signer +from django.template.loader import render_to_string + +from .templatetags.turbo_helper import dom_id + +try: + from asgiref.sync import async_to_sync + from channels.layers import get_channel_layer +except ImportError as err: + raise Exception("Please make sure django-channels is installed") from err + + +signer = Signer() + + +def stream_name_from(streamables: Union[List, object]) -> str: + """ + Generate stream_name from a list of objects or a single object. + """ + if isinstance(streamables, list): + return "_".join(stream_name_from(streamable) for streamable in streamables) + else: + return dom_id(streamables) + + +def generate_signed_stream_key(stream_name: str) -> str: + """ + Generate signed stream key from stream_name + """ + return signer.sign(stream_name) + + +def verify_signed_stream_key(signed_stream_key: str) -> Tuple[bool, str]: + """ + Verify signed stream key + """ + try: + unsigned_data = signer.unsign(signed_stream_key) + return True, unsigned_data + + except signing.BadSignature: + pass + + return False, "" + + +def generate_channel_group_name(channel: str, stream_name: str): + """ + Generate Django Channel group name from channel and stream_name + """ + return f"{channel}_{stream_name}" + + +def broadcast_render_to(streamables: Union[List, object], template: str, context=None): + 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, + { + "type": "turbo_stream_message", + "data": { + "identifier": json.dumps( + { + "channel": "TurboStreamsChannel", + "signed_stream_name": generate_signed_stream_key(stream_name), + }, + separators=(",", ":"), + ), + "message": html, + }, + }, + ) diff --git a/src/turbo_response/templatetags/turbo_helper.py b/src/turbo_response/templatetags/turbo_helper.py index 81ea79d..bb5cbb6 100644 --- a/src/turbo_response/templatetags/turbo_helper.py +++ b/src/turbo_response/templatetags/turbo_helper.py @@ -108,6 +108,29 @@ def render(self, context): return django_engine.from_string(template_string).render(context) +class TurboStreamFromTagNode(Node): + def __init__(self, stream_name_array): + self.stream_name_array = stream_name_array + + def __repr__(self): + return "<%s>" % self.__class__.__name__ + + def render(self, context): + from ..channel_helper import generate_signed_stream_key, stream_name_from + + stream_name_array = [ + stream_name.resolve(context) for stream_name in self.stream_name_array + ] + stream_name_string = stream_name_from(stream_name_array) + + django_engine = engines["django"] + template_string = """""" + context = { + "signed_stream_name": generate_signed_stream_key(stream_name_string), + } + return django_engine.from_string(template_string).render(context) + + @register.tag("turbo_frame") def turbo_frame_tag(parser, token): args = token.split_contents() @@ -173,3 +196,18 @@ def turbo_stream_tag(parser, token): parser.delete_first_token() return TurboStreamTagNode(action, target, nodelist, extra_context=extra_context) + + +@register.tag("turbo_stream_from") +def turbo_stream_from_tag(parser, token): + args = token.split_contents() + + if len(args) < 1: + raise TemplateSyntaxError( + "'turbo_stream_from' tag requires at least one arguments" + ) + + remaining_bits = args[1:] + stream_name_array = [parser.compile_filter(bit) for bit in remaining_bits] + + return TurboStreamFromTagNode(stream_name_array) diff --git a/tests/conftest.py b/tests/conftest.py index 833f2b9..71d6a94 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -31,10 +31,10 @@ def pytest_configure(): "django.contrib.sessions", "django.contrib.sites", "turbo_response", + "channels", "tests.testapp.apps.TestAppConfig", ], ROOT_URLCONF="tests.testapp.urls", - TURBO_RESPONSE_RENDERER="turbo_response.renderers.TemplatesSetting", ) diff --git a/tests/test_tags.py b/tests/test_tags.py index 277a2d2..0bf28fa 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -101,3 +101,29 @@ def test_dom_id_variable(self): output == '' ) + + +class TestStreamFrom: + def test_string(self): + template = """ + {% load turbo_helper %} + + {% turbo_stream_from "test" %} + """ + output = render(template, {}).strip() + assert ( + output + == '' + ) + + def test_dom_id_variable(self): + template = """ + {% load turbo_helper %} + + {% turbo_stream_from "test" dom_id %} + """ + output = render(template, {"dom_id": "todo_3"}).strip() + assert ( + output + == '' + ) diff --git a/tox.ini b/tox.ini index 52213ac..efba837 100644 --- a/tox.ini +++ b/tox.ini @@ -8,6 +8,7 @@ changedir=tests deps = django32: django>=3.2,<3.3 django42: django>=3.3,<4.3 + channels typing_extensions pytest pytest-django