diff --git a/CHANGELOG.md b/CHANGELOG.md index 3aa47d6..8b34408 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [2.1.3] + +1. Work with `Turbo 8 morph-refreshes` +2. Add decorator `after_create_commit, after_update_commit, after_destroy_commit`. +3. Add `broadcast_action_to`, `broadcast_refresh_to` to broadcast action to the channels. + ## [2.1.2] 1. Update to work with django-actioncable>=1.0.4 diff --git a/docs/source/index.rst b/docs/source/index.rst index a89f65b..c171cb1 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -23,5 +23,6 @@ Topics real-time-updates.md extend-turbo-stream.md multi-format.md + signal-decorator.md redirect.md test.md diff --git a/docs/source/install.md b/docs/source/install.md index 6a181a1..ddf52bc 100644 --- a/docs/source/install.md +++ b/docs/source/install.md @@ -3,7 +3,7 @@ ```{note} This package does not include any Javascript library, you may wish to add these yourself using your preferred Javascript build tool, or use a CDN. -Please check Hotwire Doc +Please check Hotwire Doc for more details ``` ## Requirements @@ -13,7 +13,7 @@ This library requires Python 3.8+ and Django 3.2+. ## Getting Started ```shell -pip install django-turbo-helper +$ pip install django-turbo-helper ``` Next update **INSTALLED_APPS**: @@ -27,21 +27,37 @@ INSTALLED_APPS = [ ## Middleware -You can optionally install `turbo_helper.middleware.TurboMiddleware`. This adds the attribute `turbo` to your `request`. +Add `turbo_helper.middleware.TurboMiddleware` to the `MIDDLEWARE` in Django settings file. ```python MIDDLEWARE = [ ... - "turbo_helper.middleware.TurboMiddleware", - "django.middleware.common.CommonMiddleware", + "turbo_helper.middleware.TurboMiddleware", # new ... ] ``` +With the `TurboMiddleware` we have `request.turbo` object which we can access in Django view or template. + If the request originates from a turbo-frame, we can get the value from the `request.turbo.frame` ```django -{% turbo_frame request.turbo.frame %} - {% include 'template.html' %} -{% endturbo_frame %} +{% load turbo_helper %} + +{% if request.turbo.frame %} + + {% turbo_frame request.turbo.frame %} + {% include 'template.html' %} + {% endturbo_frame %} + +{% endif %} +``` + +Or we can use `request.turbo.accept_turbo_stream` to check if the request accepts turbo stream response. + +```python +if request.turbo.accept_turbo_stream: + # return turbo stream response +else: + # return normal HTTP response ``` diff --git a/docs/source/multi-format.md b/docs/source/multi-format.md index 194d3fc..3557f33 100644 --- a/docs/source/multi-format.md +++ b/docs/source/multi-format.md @@ -55,12 +55,10 @@ class TaskCreateView(LoginRequiredMixin, CreateView): Notes: -1. If the browser accepts HTML, we return HTML response. -2. If the browser accepts turbo stream, we return turbo stream response. -3. This is useful when we want to migrate our Django app from normal web page to turbo stream gradually. +1. If the browser accepts turbo stream (`Accept` header should **explicitly contain** `text/vnd.turbo-stream.html`), we return turbo stream response. +2. If the browser accepts HTML (`*/*` in `Accept` also work), we return HTML response. +3. This is useful when we want to **migrate our Django app from normal web page to turbo stream gradually**. ```{note} -Most browsers send Accept: `*/*` by default, so this would return True for all content types. - -To avoid problem, it is recommned to put resp_format.html logic at the top since the order matters. +Please **put the non html response before html response**, and use html response as the fallback response. ``` diff --git a/docs/source/real-time-updates.md b/docs/source/real-time-updates.md index 15ff75c..28f8b35 100644 --- a/docs/source/real-time-updates.md +++ b/docs/source/real-time-updates.md @@ -1,4 +1,4 @@ -# Update Page in Real Time via Websocket +# Real Time Updates via Websocket Use Websocket and Turbo Stream to update the web page in real time, without writing Javascript. @@ -16,7 +16,7 @@ To import `turbo-cable-stream-source` element to the frontend, there are two way 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). +After frontend is setup, to support Actioncable protocol on the server side, please install [django-actioncable](https://github.com/AccordBox/django-actioncable). In `routing.py`, register `TurboStreamCableChannel` @@ -27,7 +27,7 @@ from turbo_helper.channels.streams_channel import TurboStreamCableChannel cable_channel_register(TurboStreamCableChannel) ``` -In Django template, we can subscribe to stream source like this: +In Django template, we can subscribe to stream source like this, it has nearly the same syntax as Rails `turbo_stream_from`: ```html {% load turbo_helper %} @@ -57,3 +57,43 @@ broadcast_render_to( 2. `keyword arguments` `template` and `context` are used to render the template. The web page can be updated in real time, through Turbo Stream over Websocket. + +## Broadcasts + +### broadcast_action_to + +Under `turbo_helper.channels.broadcasts`, there are some other helper functions to broadcast Turbo Stream to the stream source, just like Rails: + +```python +def broadcast_action_to(*streamables, action, target=None, targets=None, **kwargs): +``` + +The `broadcast_action_to` function is inspired by Rails and is designed to facilitate broadcasting actions to multiple streamable objects. It accepts a variable number of streamables as arguments, which represent the objects that will receive the broadcasted actions. + +The function requires an `action` parameter, which specifies the type of action to be performed. + +Example: + +```python +broadcast_action_to( + "chat", + instance.chat_id, + action="append", + template="message_content.html", + context={ + "instance": instance, + }, + target=dom_id(instance.chat_id, "message_list"), +) +``` + +### broadcast_refresh_to + +This is for Rails 8 refresh action, and it would broadcast something like this via the websocket to trigger the page refresh: + +```html + + +``` diff --git a/docs/source/signal-decorator.md b/docs/source/signal-decorator.md new file mode 100644 index 0000000..36e87b5 --- /dev/null +++ b/docs/source/signal-decorator.md @@ -0,0 +1,32 @@ +# Signal Decorator + +In Django, developer usually use `post_save` signal to perform certain actions after a model instance is saved. + +Even `created` parameter indicates whether the instance is newly created or an existing one, this is not that straightforward. + +With `turbo_helper`, we provide **syntax suger** to make it more clear, just like Rails. + +```python +from turbo_helper import after_create_commit, after_update_commit, after_delete_commit + + +@after_create_commit(sender=Message) +def create_message_content(sender, instance, created, **kwargs): + broadcast_action_to( + "chat", + instance.chat_id, + action="append", + template="demo_openai/message_content.html", + context={ + "instance": instance, + }, + target=dom_id(instance.chat_id, "message_list"), + ) +``` + +Notes: + +1. `after_create_commit`, `after_update_commit`, `after_delete_commit`, are decorators, they are used to decorate a function, which will be called after the model instance is created, updated or deleted. +2. The function decorated by `after_create_commit`, `after_update_commit`, receive the same arguments as `post_save` signal handler. +3. The function decorated by `after_delete_commit` receive the same arguments as `post_delete` signal handler. +4. This can make our code more clear, especially when we need to some broadcasts. diff --git a/docs/source/turbo_stream.md b/docs/source/turbo_stream.md index 6528ac4..b1329bb 100644 --- a/docs/source/turbo_stream.md +++ b/docs/source/turbo_stream.md @@ -20,7 +20,12 @@ turbo_stream.append( ) ``` -Turbo Stream built-in actions are supported in syntax `turbo_stream.xxx`: +Notes: + +1. `request`, `context` are optional +2. If `content` is not set, then `template` is required to render the `content`. + +Turbo Stream built-in actions are all supported in syntax `turbo_stream.xxx`: - append - prepend diff --git a/tests/templates/_my_form.html b/tests/templates/_my_form.html deleted file mode 100644 index 39b9e0d..0000000 --- a/tests/templates/_my_form.html +++ /dev/null @@ -1,7 +0,0 @@ -
- {% csrf_token %} - {{ form.as_p }} - -
diff --git a/tests/templates/base.html b/tests/templates/base.html deleted file mode 100644 index 5af9217..0000000 --- a/tests/templates/base.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - -

test page

- {% block content %}{% endblock %} - - - diff --git a/tests/templates/my_form.html b/tests/templates/my_form.html deleted file mode 100644 index d52bfaf..0000000 --- a/tests/templates/my_form.html +++ /dev/null @@ -1,4 +0,0 @@ -{% extends "base.html" %} -{% block content %} -{% include "_my_form.html" %} -{% endblock %} diff --git a/tests/templates/testapp/_todoitem_form.html b/tests/templates/testapp/_todoitem_form.html deleted file mode 100644 index 04cdf6a..0000000 --- a/tests/templates/testapp/_todoitem_form.html +++ /dev/null @@ -1,7 +0,0 @@ -
- {% csrf_token %} - {{ form.as_p }} - -
diff --git a/tests/templates/testapp/todoitem_form.html b/tests/templates/testapp/todoitem_form.html deleted file mode 100644 index d52bfaf..0000000 --- a/tests/templates/testapp/todoitem_form.html +++ /dev/null @@ -1,4 +0,0 @@ -{% extends "base.html" %} -{% block content %} -{% include "_my_form.html" %} -{% endblock %} diff --git a/tests/templates/todoitem.turbo_stream.html b/tests/templates/todoitem.turbo_stream.html new file mode 100644 index 0000000..5f95758 --- /dev/null +++ b/tests/templates/todoitem.turbo_stream.html @@ -0,0 +1,5 @@ +{% load turbo_helper %} + +{% turbo_stream 'append' 'todo_list' %} +
{{ instance.description }}
+{% endturbo_stream %} diff --git a/tests/test_broadcasts.py b/tests/test_broadcasts.py new file mode 100644 index 0000000..0d7b3c9 --- /dev/null +++ b/tests/test_broadcasts.py @@ -0,0 +1,114 @@ +import unittest +from unittest import mock + +import pytest + +import turbo_helper.channels.broadcasts +from tests.testapp.models import TodoItem +from tests.utils import assert_dom_equal +from turbo_helper import dom_id +from turbo_helper.channels.broadcasts import ( + broadcast_action_to, + broadcast_render_to, + broadcast_stream_to, +) + +pytestmark = pytest.mark.django_db + + +class TestBroadcastStreamTo: + def test_broadcast_stream_to(self, monkeypatch): + mock_cable_broadcast = mock.MagicMock(name="cable_broadcast") + monkeypatch.setattr( + turbo_helper.channels.broadcasts, "cable_broadcast", mock_cable_broadcast + ) + + ################################################################################ + + broadcast_stream_to("test", content="hello world") + + mock_cable_broadcast.assert_called_with( + group_name="test", message="hello world" + ) + + ################################################################################ + todo_item = TodoItem.objects.create(description="Test Model") + + broadcast_stream_to(todo_item, content="hello world") + + mock_cable_broadcast.assert_called_with( + group_name=dom_id(todo_item), message="hello world" + ) + + ################################################################################ + todo_item = TodoItem.objects.create(description="Test Model") + + broadcast_stream_to(todo_item, "test", content="hello world") + + mock_cable_broadcast.assert_called_with( + group_name=f"{dom_id(todo_item)}_test", message="hello world" + ) + + +class TestBroadcastActionTo: + def test_broadcast_action_to(self, monkeypatch): + mock_cable_broadcast = mock.MagicMock(name="cable_broadcast") + monkeypatch.setattr( + turbo_helper.channels.broadcasts, "cable_broadcast", mock_cable_broadcast + ) + + ################################################################################ + + broadcast_action_to("tasks", action="remove", target="new_task") + + assert mock_cable_broadcast.call_args.kwargs["group_name"] == "tasks" + assert_dom_equal( + mock_cable_broadcast.call_args.kwargs["message"], + '', + ) + + ################################################################################ + todo_item = TodoItem.objects.create(description="Test Model") + + broadcast_action_to(todo_item, action="remove", target="new_task") + + mock_cable_broadcast.assert_called_with( + group_name=dom_id(todo_item), message=unittest.mock.ANY + ) + + ################################################################################ + todo_item = TodoItem.objects.create(description="Test Model") + + broadcast_action_to(todo_item, "test", action="remove", target="new_task") + + mock_cable_broadcast.assert_called_with( + group_name=f"{dom_id(todo_item)}_test", message=unittest.mock.ANY + ) + + +class TestBroadcastRenderTo: + def test_broadcast_render_to(self, monkeypatch): + mock_cable_broadcast = mock.MagicMock(name="cable_broadcast") + monkeypatch.setattr( + turbo_helper.channels.broadcasts, "cable_broadcast", mock_cable_broadcast + ) + + ################################################################################ + todo_item = TodoItem.objects.create(description="test") + + broadcast_render_to( + todo_item, + template="todoitem.turbo_stream.html", + context={ + "instance": todo_item, + }, + ) + + mock_cable_broadcast.assert_called_with( + group_name=dom_id(todo_item), message=unittest.mock.ANY + ) + + assert_dom_equal( + mock_cable_broadcast.call_args.kwargs["message"], + '', + ) diff --git a/tests/test_shortcuts.py b/tests/test_shortcuts.py index a3e6350..2ece3ff 100644 --- a/tests/test_shortcuts.py +++ b/tests/test_shortcuts.py @@ -2,7 +2,7 @@ import pytest -from turbo_helper.shortcuts import redirect_303 +from turbo_helper.shortcuts import redirect_303, respond_to pytestmark = pytest.mark.django_db @@ -22,3 +22,38 @@ def test_model(self, todo): resp = redirect_303(todo) assert resp.status_code == http.HTTPStatus.SEE_OTHER assert resp.url == f"/todos/{todo.id}/" + + +class TestResponseTo: + def test_response_to(self, rf): + req = rf.get("/", HTTP_ACCEPT="*/*") + with respond_to(req) as resp: + """ + wildcard only work for HTML + """ + assert resp.html + assert not resp.turbo_stream + assert not resp.json + + req = rf.get("/", HTTP_ACCEPT="text/vnd.turbo-stream.html") + with respond_to(req) as resp: + assert resp.turbo_stream + assert not resp.html + assert not resp.json + + req = rf.get( + "/", HTTP_ACCEPT="text/html; charset=utf-8, application/json; q=0.9" + ) + with respond_to(req) as resp: + assert not resp.turbo_stream + assert resp.html + assert resp.json + + req = rf.get( + "/", + HTTP_ACCEPT="text/vnd.turbo-stream.html, text/html, application/xhtml+xml", + ) + with respond_to(req) as resp: + assert resp.turbo_stream + assert resp.html + assert not resp.json diff --git a/tests/test_signal_handler.py b/tests/test_signal_handler.py new file mode 100644 index 0000000..46a5580 --- /dev/null +++ b/tests/test_signal_handler.py @@ -0,0 +1,90 @@ +import pytest + +from tests.testapp.models import TodoItem +from turbo_helper.signals import ( + after_create_commit, + after_delete_commit, + after_update_commit, +) + +pytestmark = pytest.mark.django_db + + +class TestSignalHandler: + def test_after_create_commit_signal_handler(self): + handler_called_1 = False + + def handler_func_1(sender, instance, created, **kwargs): + nonlocal handler_called_1 + handler_called_1 = True + + handler_called_2 = False + + def handler_func_2(sender, instance, created, **kwargs): + nonlocal handler_called_2 + handler_called_2 = True + + decorated_handler = after_create_commit(sender=TodoItem)( # noqa: F841 + handler_func_1 + ) + decorated_handler_2 = after_create_commit(sender=TodoItem)( # noqa: F841 + handler_func_2 + ) + + TodoItem.objects.create(description="Test Model") + + assert handler_called_1 + assert handler_called_2 + + def test_after_update_commit_signal_handler(self): + handler_called_1 = False + + def handler_func_1(sender, instance, created, **kwargs): + nonlocal handler_called_1 + handler_called_1 = True + + handler_called_2 = False + + def handler_func_2(sender, instance, created, **kwargs): + nonlocal handler_called_2 + handler_called_2 = True + + decorated_handler = after_update_commit(sender=TodoItem)( # noqa: F841 + handler_func_1 + ) + decorated_handler_2 = after_update_commit(sender=TodoItem)( # noqa: F841 + handler_func_2 + ) + + todo_item = TodoItem.objects.create(description="Test Model") + todo_item.description = "test" + todo_item.save() + + assert handler_called_1 + assert handler_called_2 + + def test_after_delete_commit_signal_handler(self): + handler_called_1 = False + + def handler_func_1(sender, instance, **kwargs): + nonlocal handler_called_1 + handler_called_1 = True + + handler_called_2 = False + + def handler_func_2(sender, instance, **kwargs): + nonlocal handler_called_2 + handler_called_2 = True + + decorated_handler = after_delete_commit(sender=TodoItem)( # noqa: F841 + handler_func_1 + ) + decorated_handler_2 = after_delete_commit(sender=TodoItem)( # noqa: F841 + handler_func_2 + ) + + todo_item = TodoItem.objects.create(description="Test Model") + todo_item.delete() + + assert handler_called_1 + assert handler_called_2 diff --git a/tests/test_turbo_power.py b/tests/test_turbo_power.py index 62e5b58..6eec0ac 100644 --- a/tests/test_turbo_power.py +++ b/tests/test_turbo_power.py @@ -1,17 +1,11 @@ import pytest -from bs4 import BeautifulSoup +from tests.utils import assert_dom_equal from turbo_helper import turbo_stream pytestmark = pytest.mark.django_db -def assert_dom_equal(expected_html, actual_html): - expected_soup = BeautifulSoup(expected_html, "html.parser") - actual_soup = BeautifulSoup(actual_html, "html.parser") - assert str(expected_soup) == str(actual_soup) - - class TestGraft: def test_graft(self): stream = '' diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..9c62038 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,11 @@ +from bs4 import BeautifulSoup + + +def assert_dom_equal(expected_html, actual_html): + expected_soup = BeautifulSoup(expected_html, "html.parser") + actual_soup = BeautifulSoup(actual_html, "html.parser") + + expected_str = expected_soup.prettify() + actual_str = actual_soup.prettify() + + assert expected_str == actual_str