├── .bumpversion.cfg ├── .github └── workflows │ ├── chat.yml │ └── main.yml ├── .gitignore ├── .readthedocs.yml ├── CHANGELOG.md ├── CONTRIBUTORS.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── doc ├── Makefile ├── automake.sh ├── make.bat ├── requirements.txt └── source │ ├── _static │ └── css │ │ └── custom.css │ ├── conf.py │ ├── docutils.conf │ ├── index.rst │ ├── installation.rst │ ├── topics │ ├── components.rst │ ├── model_stream.rst │ ├── quickstart.rst │ ├── streams.rst │ ├── templates.rst │ └── turbo.rst │ └── tutorial │ ├── index.rst │ ├── part_1.rst │ ├── part_2.rst │ ├── part_3.rst │ ├── part_4.rst │ └── part_5.rst ├── experiments ├── chat │ ├── LICENSE │ ├── README.md │ ├── chat │ │ ├── __init__.py │ │ ├── apps.py │ │ ├── forms.py │ │ ├── migrations │ │ │ ├── 0001_initial.py │ │ │ └── __init__.py │ │ ├── models.py │ │ ├── streams.py │ │ ├── templates │ │ │ ├── base.html │ │ │ └── chat │ │ │ │ ├── components │ │ │ │ ├── create_room_form.html │ │ │ │ ├── message.html │ │ │ │ ├── room_list_item.html │ │ │ │ └── send_message_form.html │ │ │ │ ├── room_detail.html │ │ │ │ ├── room_form.html │ │ │ │ └── room_list.html │ │ └── views.py │ ├── data.json │ ├── manage.py │ ├── requirements.txt │ ├── test │ │ ├── __init__.py │ │ ├── context.py │ │ └── test_basic.py │ └── turbotutorial │ │ ├── __init__.py │ │ ├── asgi.py │ │ ├── settings.py │ │ ├── urls.py │ │ └── wsgi.py ├── components_demo │ ├── LICENSE │ ├── README.md │ ├── app │ │ ├── __init__.py │ │ ├── apps.py │ │ ├── streams.py │ │ ├── templates │ │ │ ├── app │ │ │ │ ├── components │ │ │ │ │ ├── cart_count_component.html │ │ │ │ │ └── sample_broadcast_component.html │ │ │ │ └── home.html │ │ │ └── base.html │ │ └── views.py │ ├── components_demo │ │ ├── __init__.py │ │ ├── asgi.py │ │ ├── settings.py │ │ ├── urls.py │ │ └── wsgi.py │ ├── manage.py │ ├── requirements.txt │ └── test │ │ ├── __init__.py │ │ ├── context.py │ │ └── test_basic.py ├── quickstart │ ├── LICENSE │ ├── README.md │ ├── manage.py │ ├── quickstart │ │ ├── __init__.py │ │ ├── apps.py │ │ ├── migrations │ │ │ ├── 0001_initial.py │ │ │ └── __init__.py │ │ ├── streams.py │ │ └── templates │ │ │ └── broadcast_example.html │ ├── requirements.txt │ ├── test │ │ ├── __init__.py │ │ ├── context.py │ │ └── test_basic.py │ └── turbotutorial │ │ ├── __init__.py │ │ ├── asgi.py │ │ ├── settings.py │ │ ├── urls.py │ │ └── wsgi.py └── reminders │ ├── LICENSE │ ├── README.md │ ├── manage.py │ ├── reminders │ ├── __init__.py │ ├── apps.py │ ├── forms.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── templates │ │ ├── base.html │ │ └── reminders │ │ │ ├── reminder_list.html │ │ │ ├── reminder_list_form.html │ │ │ ├── reminder_list_item.html │ │ │ └── reminder_list_items.html │ └── views.py │ ├── requirements.txt │ ├── test │ ├── __init__.py │ ├── context.py │ └── test_basic.py │ └── turbotutorial │ ├── __init__.py │ ├── asgi.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── pyproject.toml ├── requirements.txt ├── run_git_checks.sh ├── tests ├── __init__.py ├── test_app │ ├── LICENSE │ ├── README.md │ ├── app │ │ ├── __init__.py │ │ ├── asgi.py │ │ ├── settings.py │ │ ├── urls.py │ │ └── wsgi.py │ ├── manage.py │ ├── quickstart │ │ ├── __init__.py │ │ ├── apps.py │ │ ├── migrations │ │ │ ├── 0001_initial.py │ │ │ └── __init__.py │ │ ├── models.py │ │ ├── streams.py │ │ └── templates │ │ │ └── broadcast_example.html │ ├── requirements.txt │ └── test │ │ ├── __init__.py │ │ ├── context.py │ │ └── test_basic.py ├── test_basic.py ├── test_registry.py ├── test_settings.py └── test_stream.py └── turbo ├── __init__.py ├── apps.py ├── classes.py ├── components.py ├── consumers.py ├── metaclass.py ├── module_loading.py ├── registry.py ├── shortcuts.py ├── signals.py ├── static └── turbo │ └── js │ ├── reconnecting-websocket.min.js │ ├── turbo-django.js │ └── turbo.min.js ├── templates └── turbo │ ├── components │ └── broadcast_component.html │ ├── head.html │ ├── stream.html │ └── turbo_stream_source.html └── templatetags ├── __init__.py └── turbo_streams.py /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.4.3 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:pyproject.toml] 7 | -------------------------------------------------------------------------------- /.github/workflows/chat.yml: -------------------------------------------------------------------------------- 1 | name: Test chat app 2 | 3 | on: [push] 4 | defaults: 5 | run: 6 | working-directory: ./experiments/chat 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Set up Python 3.x 13 | uses: actions/setup-python@v2 14 | with: 15 | python-version: "3.8" 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install -r requirements.txt 20 | pip install ../../ 21 | - name: Setup app 22 | run: | 23 | ./manage.py migrate 24 | ./manage.py runserver & 25 | - name: Run unit tests 26 | run: pytest 27 | - name: Run integration tests 28 | run: curl http://localhost:8000/ 29 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Set up Python 3.x 12 | uses: actions/setup-python@v2 13 | with: 14 | python-version: "3.8" 15 | - name: Install dependencies 16 | run: | 17 | python -m pip install --upgrade pip 18 | pip install -r requirements.txt 19 | pip install . 20 | - name: Run linter 21 | run: pflake8 turbo 22 | - name: Run formatter 23 | run: black -S --target-version=py38 --line-length=100 --check . --exclude "doc|migrations" 24 | - name: Run unit tests 25 | run: pytest tests 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | *.sqlite3 3 | *.pyc 4 | __pycache__/ 5 | .DS_Store 6 | turbo_django.egg-info/ 7 | ve 8 | venv 9 | .vscode 10 | doc/build 11 | build/* 12 | poetry.lock 13 | dist/ 14 | 15 | .coverage 16 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | build: 9 | os: ubuntu-22.04 10 | tools: 11 | python: "3.8" 12 | 13 | # Build documentation in the docs/ directory with Sphinx 14 | sphinx: 15 | configuration: doc/source/conf.py 16 | 17 | # Build documentation with MkDocs 18 | #mkdocs: 19 | # configuration: mkdocs.yml 20 | 21 | # Optionally build your docs in additional formats such as PDF 22 | formats: 23 | - pdf 24 | 25 | 26 | python: 27 | install: 28 | - requirements: doc/requirements.txt 29 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Turbo-Django Changelog 2 | 3 | 0.4.2 - 2022-05-13 4 | * Removes duplicate and incorrect render method from UserBroadcastComponent. 5 | 6 | 0.4.1 - 2022-05-13 7 | * Moves correct render() method to BaseComponent 8 | 9 | 0.4.0 - 2022-05-12 10 | * Adds Turbo Components, easy to add turbo-frames with an associated stream and template. 11 | 12 | 0.3.0 - 2021-12-05 13 | * Stream class added to explicitly declare streams 14 | * Streams auto-detected in streams.py 15 | * TurboMixin has been removed. ModelStreams replace this functionality with linked model declared in Meta.model 16 | * Permissions can now be written by overriding the Stream.user_passes_test() method 17 | * Support for stream-less turbo-frame responses to POST requests 18 | 19 | 0.2.5 - 2021-12-05 20 | * Update Turbo library to 7.1.0 21 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | - [Nikita Marchant](https://github.com/C4ptainCrunch) 2 | - [Davis Haupt](https://github.com/davish) 3 | - [Bo Lopker](https://github.com/blopker) 4 | - [Julian Feinauer](https://github.com/JulianFeinauer/) 5 | - [Edgard Pineda](https://github.com/epineda/) 6 | - [Stephen Mitchell](https://github.com/scuml/) 7 | 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright 2020 by the contributors listed in CONTRIBUTORS.md 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice including CONTRIBUTORS.md, and this permission notice 14 | shall be included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | recursive-include turbo/templates/turbo/templates * 4 | recursive-include turbo/static/turbo/js * 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Fhotwire-django%2Fturbo-django%2Fbadge%3Fref%3Dmain&style=flat)](https://actions-badge.atrox.dev/hotwire-django/turbo-django/goto?ref=main) 2 | [![Documentation Status](https://readthedocs.org/projects/turbo-django/badge/?version=latest)](https://turbo-django.readthedocs.io/en/latest/?badge=latest) 3 | [![Issues](https://img.shields.io/github/issues/hotwire-django/turbo-django)](https://img.shields.io/github/issues/hotwire-django/turbo-django) 4 | [![Twitter](https://img.shields.io/twitter/url?style=social&url=https%3A%2F%2Ftwitter.com%2FDjangoHotwire)](https://twitter.com/intent/tweet?text=Wow:&url=https%3A%2F%2Fgithub.com%2Fhotwire-django%2Fturbo-django) 5 | 6 | # Unmaintained // Turbo for Django 7 | 8 | > [!WARNING] 9 | > This library is unmaintained. Integrating Hotwire and Django is so easy 10 | > that you are probably better served by writing a little bit of Python in your code 11 | > than using a full-blown library that adds another level of abstraction. 12 | > It also seems that the Django community is leaning more towards HTMX than Hotwire 13 | > so you might want to look over there if you want more "support" 14 | > (but we still think that Hotwire is very well suited to be used with Django) 15 | 16 | Integrate [Hotwire Turbo](https://turbo.hotwired.dev/) with Django with ease. 17 | 18 | 19 | ## Requirements 20 | 21 | - Python 3.8+ 22 | - Django 3.1+ 23 | - Channels 3.0+ _(Optional for Turbo Frames, but needed for Turbo Stream support)_ 24 | 25 | ## Installation 26 | 27 | Turbo Django is available on PyPI - to install it, just run: 28 | 29 | pip install turbo-django 30 | 31 | Add `turbo` and `channels` to `INSTALLED_APPS`, and copy the following `CHANNEL_LAYERS` setting: 32 | 33 | ```python 34 | INSTALLED_APPS = [ 35 | ... 36 | 'turbo', 37 | 'channels' 38 | ... 39 | ] 40 | 41 | CHANNEL_LAYERS = { 42 | "default": { 43 | # You will need to `pip install channels_redis` and configure a redis instance. 44 | # Using InMemoryChannelLayer will not work as the memory is not shared between threads. 45 | # See https://channels.readthedocs.io/en/latest/topics/channel_layers.html 46 | "BACKEND": "channels_redis.core.RedisChannelLayer", 47 | "CONFIG": { 48 | "hosts": [("127.0.0.1", 6379)], 49 | }, 50 | } 51 | } 52 | 53 | ``` 54 | 55 | And collect static files if the development server is not hosting them: 56 | 57 | ```sh 58 | ./manage.py collectstatic 59 | ``` 60 | 61 | _Note: Both Hotwire and this library are still in beta development and may introduce breaking API changes between releases. It is advised to pin the library to a specific version during install._ 62 | 63 | ## Quickstart 64 | Want to see Hotwire in action? Here's a simple broadcast that can be setup in less than a minute. 65 | 66 | **The basics:** 67 | 68 | * A Turbo Stream class is declared in python. 69 | 70 | * A template subscribes to the Turbo Stream. 71 | 72 | * HTML is be pushed to all subscribed pages which replaces the content of specified HTML p tag. 73 | 74 | 75 | ### Example 76 | 77 | First, in a django app called `quickstart`, declare `BroadcastStream` in a file named `streams.py`. 78 | 79 | ```python 80 | # streams.py 81 | 82 | import turbo 83 | 84 | class BroadcastStream(turbo.Stream): 85 | pass 86 | 87 | ``` 88 | 89 | Then, create a template that subscribes to the stream. 90 | 91 | ```python 92 | from django.urls import path 93 | from django.views.generic import TemplateView 94 | 95 | urlpatterns = [ 96 | path('quickstart/', TemplateView.as_view(template_name='broadcast_example.html')) 97 | ] 98 | ``` 99 | 100 | ```html 101 | # broadcast_example.html 102 | 103 | {% load turbo_streams %} 104 | 105 | 106 | 107 | {% include "turbo/head.html" %} 108 | 109 | 110 | {% turbo_subscribe 'quickstart:BroadcastStream' %} 111 | 112 |

Placeholder for broadcast

113 | 114 | 115 | ``` 116 | 117 | Now run ``./manage.py shell``. Import the Turbo Stream and tell the stream to take the current timestamp and ``update`` the element with id `broadcast_box` on all subscribed pages. 118 | 119 | ```python 120 | from quickstart.streams import BroadcastStream 121 | from datetime import datetime 122 | 123 | BroadcastStream().update(text=f"The date and time is now: {datetime.now()}", id="broadcast_box") 124 | ``` 125 | 126 | With the `quickstart/` path open in a browser window, watch as the broadcast pushes messages to the page. 127 | 128 | Now change `.update()` to `.append()` and resend the broadcast a few times. Notice you do not have to reload the page to get this modified behavior. 129 | 130 | Excited to learn more? Be sure to walk through the [tutorial](https://turbo-django.readthedocs.io/en/latest/index.html) and read more about what Turbo can do for you. 131 | 132 | ## Documentation 133 | Read the [full documentation](https://turbo-django.readthedocs.io/en/latest/index.html) at readthedocs.io. 134 | 135 | 136 | ## Contribute 137 | 138 | Discussions about a Django/Hotwire integration are happening on the [Hotwire forum](https://discuss.hotwired.dev/t/django-backend-support-for-hotwire/1570). And on Slack, which you can join by [clicking here!](https://join.slack.com/t/pragmaticmindsgruppe/shared_invite/zt-kl0e0plt-uXGQ1PUt5yRohLNYcVvhhQ) 139 | 140 | As this new magic is discovered, you can expect to see a few repositories with experiments and demos appear in [@hotwire-django](https://github.com/hotwire-django). If you too are experimenting, we encourage you to ask for write access to the GitHub organization and to publish your work in a @hotwire-django repository. 141 | 142 | 143 | ## License 144 | 145 | Turbo-Django is released under the [MIT License](https://opensource.org/licenses/MIT) to keep compatibility with the Hotwire project. 146 | 147 | If you submit a pull request. Remember to add yourself to `CONTRIBUTORS.md`! 148 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /doc/automake.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # This script detects changes in doc files and automatically triggers a rebuild. 4 | 5 | FOO=$(mktemp /tmp/turbodocs-automake.XXXXXX) 6 | while true; do 7 | for SRC in $(find . -name '*.rst' -mmin -1); do 8 | if [ "$SRC" -nt "$FOO" ]; then 9 | touch $FOO 10 | make clean html 11 | date 12 | break 13 | fi 14 | done 15 | sleep 1 16 | done 17 | 18 | -------------------------------------------------------------------------------- /doc/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /doc/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx-autoapi==1.8.1 2 | sphinx-rtd-theme==0.5.1 3 | sphinxcontrib-fulltoc==1.2.0 4 | sphinx_toolbox==2.13.0 5 | -e git+https://github.com/hotwire-django/sphinx-hotwire-theme.git@main#egg=alabaster-hotwire 6 | jinja2==3.0.0 7 | -------------------------------------------------------------------------------- /doc/source/_static/css/custom.css: -------------------------------------------------------------------------------- 1 | div.document { 2 | width: 75%; 3 | } 4 | div.body { 5 | min-width: 450px; 6 | max-width: 100%; 7 | } 8 | -------------------------------------------------------------------------------- /doc/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | import os 17 | import sys 18 | 19 | sys.path.insert(0, os.path.abspath("../")) 20 | 21 | 22 | # -- Project information ----------------------------------------------------- 23 | 24 | project = 'turbo-django' 25 | copyright = '2022, Hotwire-Django Team' 26 | author = 'Hotwire-Django Team' 27 | 28 | 29 | # -- General configuration --------------------------------------------------- 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = [ 35 | 'sphinxcontrib.fulltoc', 36 | 'sphinx.ext.autodoc', 37 | # 'autoapi.extension', 38 | 'sphinx_toolbox.code', 39 | ] 40 | 41 | # Add any paths that contain templates here, relative to this directory. 42 | templates_path = ['_templates'] 43 | 44 | # List of patterns, relative to source directory, that match files and 45 | # directories to ignore when looking for source files. 46 | # This pattern also affects html_static_path and html_extra_path. 47 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store', 'venv'] 48 | 49 | # Document Python Code 50 | autoapi_type = "python" 51 | autoapi_dirs = ["../../turbo"] 52 | autoapi_ignore = ["*/tests/*.py"] 53 | autodoc_typehints = "description" 54 | 55 | # -- Options for HTML output ------------------------------------------------- 56 | 57 | # The theme to use for HTML and HTML Help pages. See the documentation for 58 | # a list of builtin themes. 59 | # 60 | html_theme = 'alabaster_hotwire' 61 | 62 | # Add any paths that contain custom static files (such as style sheets) here, 63 | # relative to this directory. They are copied after the builtin static files, 64 | # so a file named "default.css" will overwrite the builtin "default.css". 65 | html_static_path = ['_static'] 66 | 67 | html_context = { 68 | 'topbar': [ 69 | {"url": "https://turbo-django.readthedocs.io/", "name": "Turbo Django", "active": True}, 70 | {"url": "https://django-turbo-response.readthedocs.io/", "name": "Django Turbo Response"}, 71 | {"name": "Stimulus Django"}, 72 | ] 73 | } 74 | 75 | html_css_files = [ 76 | 'css/custom.css', 77 | ] 78 | -------------------------------------------------------------------------------- /doc/source/docutils.conf: -------------------------------------------------------------------------------- 1 | [restructuredtext parser] 2 | tab_width: 4 3 | -------------------------------------------------------------------------------- /doc/source/index.rst: -------------------------------------------------------------------------------- 1 | .. warning:: 2 | This library is unmaintained. Integrating Hotwire and Django is so easy 3 | that you are probably better served by writing a little bit of Python in your code 4 | than using a full blown library that adds another level of abstraction. 5 | It also seems that the Django community is leaning more towards HTMX than Hotwire 6 | so you might want to look over there if you want more "support" 7 | (but we still think that Hotwire is very well suited to be used with Django) 8 | 9 | 10 | 11 | Unmaintained // Turbo Django 12 | ============ 13 | 14 | Turbo Django is a project that integrates the `Hotwire Turbo framework `_ with `Django `_, allowing for rendered page updates to be delivered live, over the wire. By keeping template rendering in Django, dynamic and interactive web pages can be written without any serialization frameworks or JavaScript, dramatically simplifying development. 15 | 16 | Topics 17 | ------ 18 | 19 | .. toctree:: 20 | :maxdepth: 2 21 | 22 | installation 23 | topics/quickstart.rst 24 | tutorial/index 25 | topics/turbo.rst 26 | topics/streams.rst 27 | topics/model_stream.rst 28 | topics/components.rst 29 | topics/templates.rst 30 | 31 | 32 | Reference 33 | --------- 34 | 35 | .. toctree:: 36 | :maxdepth: 1 37 | 38 | GitHub Repo 39 | -------------------------------------------------------------------------------- /doc/source/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | ============ 5 | Requirements 6 | ============ 7 | 8 | This library is tested for Python 3.8+ and Django 3.1+ 9 | 10 | ============ 11 | Installation 12 | ============ 13 | 14 | 15 | Turbo Django is available on PyPI - to install it, just run: 16 | 17 | .. code-block:: sh 18 | 19 | pip install turbo-django 20 | 21 | 22 | .. note:: 23 | 24 | Both Turbo and Turbo Django are under beta development and the API can change quickly as new features are added and the API is refined. It would be prudent to pin to a specific version until the first major release. You can pin with pip using a command like ``pip install turbo-django==0.3.0``. The latest version can be found `at PyPi `_. 25 | 26 | Once that's done, you should add ``turbo`` and ``channels`` to your 27 | ``INSTALLED_APPS`` setting: 28 | 29 | .. code-block:: python 30 | 31 | INSTALLED_APPS = ( 32 | 'django.contrib.auth', 33 | 'django.contrib.contenttypes', 34 | 'django.contrib.sessions', 35 | 'django.contrib.sites', 36 | ... 37 | 'turbo', 38 | 'channels', 39 | ) 40 | 41 | CHANNEL_LAYERS = { 42 | # You will need to `pip install channels_redis` and configure a redis instance. 43 | # Using InMemoryChannelLayer will not work as the stored memory is not shared between threads. 44 | # See https://channels.readthedocs.io/en/latest/topics/channel_layers.html 45 | "default": { 46 | "BACKEND": "channels_redis.core.RedisChannelLayer", 47 | "CONFIG": { 48 | "hosts": [("127.0.0.1", 6379)], 49 | }, 50 | }, 51 | } 52 | 53 | 54 | .. note:: 55 | Turbo relies on the ``channels`` library to push data to the client (also known as Turbo Streams). Adding channels may not be needed if using only implementing Turbo Frames to component-ify your app. For the tutorial, you will need channels installed. 56 | 57 | 58 | Then, adjust your project's ``asgi.py`` to wrap the Django ASGI application:: 59 | 60 | import os 61 | 62 | from django.core.asgi import get_asgi_application 63 | from channels.routing import ProtocolTypeRouter 64 | from channels.auth import AuthMiddlewareStack 65 | from turbo.consumers import TurboStreamsConsumer 66 | 67 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings') 68 | 69 | 70 | application = ProtocolTypeRouter({ 71 | "http": get_asgi_application(), 72 | "websocket": AuthMiddlewareStack(TurboStreamsConsumer.as_asgi()), 73 | }) 74 | 75 | And finally, set your ``ASGI_APPLICATION`` setting to point to that routing 76 | object as your root application: 77 | 78 | .. code-block:: python 79 | 80 | ASGI_APPLICATION = "myproject.asgi.application" 81 | 82 | All set! ``turbo`` is now ready to use in your Django app. 83 | -------------------------------------------------------------------------------- /doc/source/topics/components.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Components 3 | ========== 4 | 5 | 6 | Components are a subclass of `Stream` that simplifies implementation of streams. 7 | 8 | 9 | 10 | Creating a Component 11 | ==================== 12 | 13 | Components are a type of stream with a template. As components are a type of stream, components must be either be created in or imported to `streams.py` to be registered. 14 | 15 | 16 | Quick example 17 | ------------- 18 | 19 | .. code-block:: python 20 | :caption: app/streams.py 21 | 22 | from turbo.components import BroadcastComponent 23 | 24 | class AlertBroadcastComponent(BroadcastComponent): 25 | 26 | template_name = "components/sample_broadcast_component.html" 27 | 28 | 29 | Add a simple template: 30 | 31 | .. code-block:: html 32 | :caption: templates/components/sample_broadcast_component.html 33 | 34 | {% if alert_content %} 35 | 38 | {% endif %} 39 | 40 | The component can be rendered in one of two ways. 41 | 42 | .. code-block:: html 43 | :caption: templates/view_template.html 44 | 45 | {% load turbo_streams %} 46 | 47 | If an instance of the component is passed in via the view: 48 | {% turbo_component alert_component %} 49 | 50 | To access the component globally: 51 | {% turbo_component "app_name:AlertBroadcastComponent" %} 52 | 53 | 54 | This will insert the contents of `components/sample_broadcast_component.html` on the template. 55 | 56 | To stream updated content to the view, open a python terminal, instanciate the component, and render a new update. 57 | 58 | 59 | .. code-block:: python 60 | 61 | from app_name.streams import AlertBroadcastComponent 62 | alert_component = AlertBroadcastComponent() 63 | alert_component.render( 64 | alert_class='warning', 65 | alert_content='The server will restart in 10 minutes' 66 | ) 67 | 68 | 69 | .. admonition:: Multiple identical components 70 | 71 | Using the same component twice in a template, will only update the first component on the page. This is a current purposeful limitation of the Hotwire framework. In the above examples, while the components will render initially, only the first component will receive the streamed content. 72 | 73 | 74 | 75 | 76 | Full example 77 | ------------ 78 | 79 | .. code-block:: python 80 | :caption: app/streams.py 81 | 82 | from turbo.components import BroadcastComponent 83 | 84 | class AlertBroadcastComponent(BroadcastComponent): 85 | 86 | template_name = "components/sample_broadcast_component.html" 87 | 88 | def get_context(self): 89 | """ 90 | Return the default context to render a component. 91 | """ 92 | return {} 93 | 94 | def user_passes_test(self, user): 95 | """ 96 | Only allow access to the component stream if the user passes 97 | this test. 98 | """ 99 | return user.is_authenticated 100 | 101 | 102 | .. module:: turbo.components 103 | 104 | BroadcastComponent 105 | ================== 106 | 107 | .. class:: BroadcastComponent 108 | 109 | A broadcast component will stream a template to all users. 110 | 111 | Example 112 | ------- 113 | 114 | .. code-block:: python 115 | :caption: app/streams.py 116 | 117 | from turbo.components import BroadcastComponent 118 | 119 | class AlertBroadcastComponent(BroadcastComponent): 120 | template_name = "components/sample_broadcast_component.html" 121 | 122 | .. code-block:: html 123 | :caption: templates/components/sample_broadcast_component.html 124 | 125 | {% if alert_content %} 126 | 129 | {% endif %} 130 | 131 | .. code-block:: html 132 | :caption: templates/view_template.html 133 | 134 | {% load turbo_streams %} 135 | 136 | {% turbo_component "app_name:AlertBroadcastComponent" %} 137 | 138 | 139 | 140 | To stream an updated template to the component: 141 | 142 | .. code-block:: python 143 | 144 | from .streams import AlertBroadcastComponent 145 | 146 | component = AlertBroadcastComponent() 147 | component.render( 148 | alert_class='warning', 149 | alert_content='The server will restart in 10 minutes' 150 | ) 151 | 152 | 153 | UserBroadcastComponent 154 | ====================== 155 | 156 | .. class:: UserBroadcastComponent 157 | 158 | A user broadcast component will stream a template to a specific user. 159 | 160 | Example 161 | ------- 162 | 163 | .. code-block:: python 164 | :caption: app/streams.py 165 | 166 | from turbo.components import UserBroadcastComponent 167 | 168 | class CartCountComponent(UserBroadcastComponent): 169 | template_name = "components/cart_count_component.html" 170 | 171 | def get_context(self): 172 | return { 173 | "count": self.user.cart.items_in_cart 174 | } 175 | 176 | 177 | .. code-block:: html 178 | :caption: templates/components/cart_count_component.html 179 | 180 | 191 | 192 | 193 | .. code-block:: html 194 | :caption: templates/view_template.html 195 | 196 | {% load turbo_streams %} 197 | 198 | {% turbo_component "chat:CartCountComponent" request.user %} 199 | or 200 | {% turbo_component cart_count_component %} 201 | 202 | 203 | -------------------------------------------------------------------------------- /doc/source/topics/model_stream.rst: -------------------------------------------------------------------------------- 1 | ================= 2 | ModelStream 3 | ================= 4 | 5 | 6 | A common reason to stream data is to send page updates when a model is created, modified, or deleted. To organize these events in one place, Turbo Django uses ``ModelStream``. These classes will trigger the code to run when a model instance is saved and deleted. ``ModelStream`` objects are declared and automatically detected in ``streams.py``. 7 | 8 | When a ModelStream is registered to a model, the model instance will automatically gain a `.stream` attribute that references the stream. For this reason, only one model stream can be attached to each model. 9 | 10 | .. admonition:: Primary Key Needed 11 | 12 | You can only broadcast to instances that have a primary key. A ``ValueError`` is thrown when trying to broadcast to an object that does not have a primary key set. 13 | 14 | 15 | Example 16 | ---------------------- 17 | 18 | The following demonstrates a sample implementation of ModelStreams for a chat application. In this examples, a user would subscribe to a Room, however, the messages are the items being added and removed. A stream is created for both models - giving them both `.stream` attributes. When the message is saved, the message then references it's parent room stream, and either appends or replaces the chat message if it was created or modified. If the message is deleted, the parent room stream is notified to remove the message block with the provided id. 19 | 20 | .. code-block:: python 21 | :caption: app/streams.py 22 | 23 | from .models import Message, Room 24 | 25 | import turbo 26 | 27 | class RoomStream(turbo.ModelStream): 28 | 29 | class Meta: 30 | model = Room 31 | 32 | 33 | 34 | class MessageStream(turbo.ModelStream): 35 | 36 | class Meta: 37 | model = Message 38 | 39 | def on_save(self, message, created, *args, **kwargs): 40 | if created: 41 | message.room.stream.append("chat/message.html", {"message": message}, id="messages") 42 | else: 43 | message.room.stream.replace("chat/message.html", {"message": message}, id=f"message-{message.id}") 44 | 45 | def on_delete(self, message, *args, **kwargs): 46 | message.room.stream.remove(id=f"message-{message.id}") 47 | 48 | def user_passes_test(self, user): 49 | # if user.can_access_message(self.pk): 50 | # return True 51 | return True 52 | -------------------------------------------------------------------------------- /doc/source/topics/quickstart.rst: -------------------------------------------------------------------------------- 1 | .. warning:: 2 | This library is unmaintained. Integrating Hotwire and Django is so easy 3 | that you are probably better served by writing a little bit of Python in your code 4 | than using a full blown library that adds another level of abstraction. 5 | It also seems that the Django community is leaning more towards HTMX than Hotwire 6 | so you might want to look over there if you want more "support" 7 | (but we still think that Hotwire is very well suited to be used with Django) 8 | 9 | ========== 10 | Unmaintained//Quickstart 11 | ========== 12 | 13 | Want to see Turbo in action? Here's a simple broadcast that can be setup in less than a minute. 14 | 15 | **The basics:** 16 | 17 | * A Turbo Stream class is declared in python. 18 | 19 | * A template subscribes to the Turbo Stream. 20 | 21 | * HTML is be pushed to all subscribed pages which replaces the content of specified HTML p tag. 22 | 23 | 24 | Example 25 | ============= 26 | 27 | First, declare the Stream. 28 | 29 | .. code-block:: python 30 | :caption: streams.py 31 | 32 | import turbo 33 | 34 | class BroadcastStream(turbo.Stream): 35 | pass 36 | 37 | 38 | Then, create a template that subscribes to the stream. 39 | 40 | .. code-block:: python 41 | :caption: urls.py 42 | 43 | from django.urls import path 44 | from django.views.generic import TemplateView 45 | 46 | urlpatterns = [ 47 | path('', TemplateView.as_view(template_name='broadcast_example.html')) 48 | ] 49 | 50 | 51 | .. code-block:: html 52 | :caption: broadcast_example.html 53 | 54 | {% load turbo_streams %} 55 | 56 | 57 | 58 | {% include "turbo/head.html" %} 59 | 60 | 61 | {% turbo_subscribe 'quickstart:BroadcastStream' %} 62 | 63 |

Placeholder for broadcast

64 | 65 | 66 | 67 | .. note:: 68 | Broadcasts can target any HTML element on a page subscribed to its stream. Target elements do not need be wrapped in any ``turbo`` style tag. 69 | 70 | 71 | Now open ``./manage.py shell``. Import the Turbo Stream and tell the stream to take the current timestamp and ``update`` the element with id `broadcast_box` on all subscribed pages. 72 | 73 | .. code-block:: python 74 | 75 | from quickstart.streams import BroadcastStream 76 | from datetime import datetime 77 | 78 | BroadcastStream().update(text=f"{datetime.now()}: This is a broadcast.", id="broadcast_box") 79 | 80 | With the ``quickstart/`` path open in a browser window, watch as the broadcast pushes messages to the page. 81 | 82 | Now change ``.update()`` to ``.append()`` and resend the broadcast a few times. Notice you do not have to reload the page to get this modified behavior. 83 | 84 | Excited to learn more? Be sure to walk through the :doc:`tutorial ` and read more about what the :doc:`Turbo ` class can do. 85 | -------------------------------------------------------------------------------- /doc/source/topics/streams.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Streams 3 | ======= 4 | 5 | Streams allow data to be sent to a currently loaded page. Stream classes must be explicitly declared in `streams.py` and should contain all render and positioning logic. Stream classes are also used to add permissions. 6 | 7 | 8 | Example 9 | ---------------------- 10 | 11 | .. code-block:: python 12 | :caption: app/streams.py 13 | 14 | from .models import Message, Room 15 | 16 | import turbo 17 | 18 | class BroadcastStream(turbo.Stream): 19 | 20 | def send_message(self, message): 21 | # This is a user-defined method that encapsulates render and positioning logic. 22 | # It would be called from code using BroadcastStream().send_message("test message") 23 | self.update(text=message, id="broadcast_box") 24 | 25 | def user_passes_test(self, user): 26 | # user_passes_test is a built-in method that is extended to add permissions to streams. 27 | return True 28 | 29 | 30 | .. module:: turbo.Stream 31 | 32 | 33 | .. method:: append(template=None, context=None, text=None, selector=None, id=None) 34 | 35 | Add the rendered template to the end of the specified HTML element. 36 | 37 | .. method:: prepend(template=None, context=None, text=None, selector=None, id=None) 38 | 39 | Add the rendered template to the beginning of the specified HTML element. 40 | 41 | .. method:: replace(template=None, context=None, text=None, selector=None, id=None) 42 | 43 | Remove and replace the specified HTML element with the rendered template. 44 | 45 | .. method:: update(template=None, context=None, text=None, selector=None, id=None) 46 | 47 | Replace the contents inside the specified HTML element with the rendered template. 48 | 49 | .. method:: before(template=None, context=None, text=None, selector=None, id=None) 50 | 51 | Insert the rendered template before the specified HTML element. 52 | 53 | .. method:: after(template=None, context=None, text=None, selector=None, id=None) 54 | 55 | Insert the template after the specified HTML element. 56 | 57 | .. method:: remove(selector=None, id=None) 58 | 59 | Remove the given HTML element. The rendered template will not be used. As no template is used to remove divs, this can also be called directly from the shortcut ``remove_frame()``. Ex: ``remove_frame(id='div_to_remove')`` 60 | 61 | .. method:: stream(frame: "TurboRender") 62 | 63 | Send a :doc:`TurboRender ` object to this stream. 64 | 65 | .. method:: stream_raw(raw_text: str) 66 | 67 | Send raw text to this stream. This will not be prewrapped in a turbo stream tag as it would be in `stream()` 68 | 69 | .. method:: user_passes_test(user) -> bool 70 | 71 | Return True if a user has permission to access this stream. If False, the websocket connection will be rejected. When creating a stream, extend this method to exclude certain users from resources. 72 | 73 | 74 | -------------------------------------------------------------------------------- /doc/source/topics/templates.rst: -------------------------------------------------------------------------------- 1 | Templates 2 | ========== 3 | 4 | Templates subscribe to streams using the ``turbo_subscribe`` template tag. Import this tag by calling ``{% load turbo_streams %}``. Pass a channel object, name-spaced channel name as a string, or pass a Django instance to listen to messages sent to a particular object. This tag can be called anywhere on the page and can be called multiple times if desired. 5 | 6 | .. code-block:: html 7 | :caption: broadcast_example.html 8 | 9 | 10 | {% load turbo_streams %} 11 | 12 | 13 | {% turbo_subscribe RoomListChannel %} 14 | {% turbo_subscribe 'chat:RoomListChannel' %} 15 | {% turbo_subscribe room %} 16 | 17 | 18 | {% turbo_subscribe 'chat:RoomListChannel' room %} 19 | 20 | 21 | 22 | 23 | It is now possible to send and place html to the subscribed page using the following: 24 | 25 | .. code-block:: python 26 | 27 | from turbo import Turbo 28 | 29 | # Send to a standard Channel 30 | RoomListChannel.replace( 31 | "alert.html", 32 | {'message': 'Server restart in 1 minute.'}, 33 | id='alert_div' 34 | ) 35 | 36 | 37 | # Send to a ModelStream 38 | room = Room.objects.first() 39 | 40 | room.channel.append( 41 | "new_message.html", 42 | {'message': 'New message'} 43 | id='messages_container' 44 | ) 45 | 46 | 47 | 48 | 49 | ``turbo_subscribe tag`` 50 | ----------------------- 51 | 52 | Tells the page to subscribe to the channel or instance. 53 | 54 | Example usage:: 55 | 56 | {% load turbo_streams %} 57 | {% turbo_subscribe 'chat:BroadcastChannel' %} 58 | 59 | Stream names can be strings or generated from instances:: 60 | 61 | {% turbo_subscribe room %} 62 | 63 | Listen to multiple streams by adding additional arguments:: 64 | 65 | {% turbo_subscribe 'chat:BroadcastChannel' room %} 66 | 67 | -------------------------------------------------------------------------------- /doc/source/topics/turbo.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | Turbo Frames 3 | ============= 4 | 5 | **** Turbo Frames allow parts of the page to be updated on request. Each turbo-frame must have an id that is shared between the parent frame, and the elements that will be loaded into the frame. 6 | 7 | .. note:: 8 | Be sure to read the `official documentation of Turbo Frames `_. 9 | 10 | 11 | 12 | .. module:: turbo.Turbo 13 | 14 | Turbo Frames can be rendered in python using convience methods. 15 | 16 | .. module:: turbo.shortcuts 17 | 18 | .. method:: render_frame(request, template_name: str, context=None) -> TurboRender 19 | 20 | .. method:: render_frame_string(text: str) -> TurboRender 21 | 22 | .. method:: remove_frame(selector=None, id=None) -> TurboRender 23 | 24 | Create a TurboRender object that removes a frame. Since there is no content to be inserted, no template or text is passed. Instead, 25 | 26 | .. code-block:: python 27 | 28 | from turbo.shortcuts import render_frame, remove_frame 29 | 30 | def post(self, request, *args, **kwargs): 31 | 32 | form = RoomForm(request.POST) 33 | if form.is_valid(): 34 | form.save() 35 | 36 | new_form = RoomForm() 37 | 38 | return ( 39 | render_frame( 40 | request, 41 | "chat/components/create_room_form.html", 42 | {"form": new_form}, 43 | ) 44 | .replace(id="create-room-form") 45 | .response 46 | ) 47 | 48 | 49 | 50 | TurboRender methods 51 | =================== 52 | 53 | .. module:: turbo.TurboRender 54 | 55 | Once a turbo frame has been rendered, it needs to know where to position itself. The following methods let the client page know where to position the new content when it is received. 56 | 57 | The typical use is to chain ``render`` and ``.`` commands into one logical, easy-to-read statement. 58 | 59 | .. code-block:: python 60 | 61 | render_frame( 62 | request, 'broadcast.html', {'content': "New message!"} 63 | ).update(".alert_box") 64 | 65 | Each of the following methods take either an ``selector`` or ``id`` keyword argument to specify which HTML element will receive the action. ``selector`` is the first argument, so no keyword specifier is needed. 66 | 67 | 68 | .. method:: append(selector=None, id=None) 69 | 70 | Add the rendered template to the end of the specified HTML element. 71 | 72 | .. method:: prepend(selector=None, id=None) 73 | 74 | Add the rendered template to the beginning of the specified HTML element. 75 | 76 | .. method:: replace(selector=None, id=None) 77 | 78 | Remove and replace the specified HTML element with the rendered template. 79 | 80 | .. method:: update(selector=None, id=None) 81 | 82 | Replace the contents inside the specified HTML element with the rendered template. 83 | 84 | .. method:: remove(selector=None, id=None) 85 | 86 | Remove the given HTML element. The rendered template will not be used. As no template is used to remove divs, this can also be called directly from the shortcut ``remove_frame()``. Ex: ``remove_frame(id='div_to_remove')`` 87 | 88 | .. method:: before(selector=None, id=None) 89 | 90 | Insert the rendered template before the specified HTML element. 91 | 92 | .. method:: after(selector=None, id=None) 93 | 94 | Insert the template after the specified HTML element. 95 | 96 | .. method:: response 97 | 98 | Property. Return this rendered template as an HttpResponse with a "text/vnd.turbo-stream.html" content type. This allows for turbo-stream elements to be returned from a form submission. See the Turbo documentation for more detail (https://turbo.hotwired.dev/handbook/drive#streaming-after-a-form-submission) 99 | 100 | .. code-block:: python 101 | 102 | frame = render_frame( 103 | request, "reminders/reminder_list_item.html", {'reminder': reminder} 104 | ).append(id='reminders') 105 | return frame.response 106 | 107 | -------------------------------------------------------------------------------- /doc/source/tutorial/index.rst: -------------------------------------------------------------------------------- 1 | .. warning:: 2 | This library is unmaintained. Integrating Hotwire and Django is so easy 3 | that you are probably better served by writing a little bit of Python in your code 4 | than using a full blown library that adds another level of abstraction. 5 | It also seems that the Django community is leaning more towards HTMX than Hotwire 6 | so you might want to look over there if you want more "support" 7 | (but we still think that Hotwire is very well suited to be used with Django) 8 | 9 | 10 | Unmaintained//Tutorial 11 | ======== 12 | 13 | Turbo-Django allows you to easily integrate the Hotwire Turbo framework into your 14 | Django site. This will allow clients to receive blocks of html sent from your web server 15 | without using HTTP long-polling or other expensive techniques. This makes for 16 | dynamic interactive webpages, without all the mucking about with serializers and JavaScript. 17 | 18 | In this tutorial we will build a simple chat server, where you can join an 19 | online room, post messages to the room, and have others in the same room see 20 | those messages immediately. 21 | 22 | .. toctree:: 23 | :maxdepth: 1 24 | 25 | part_1 26 | part_2 27 | part_3 28 | part_4 29 | part_5 30 | -------------------------------------------------------------------------------- /doc/source/tutorial/part_1.rst: -------------------------------------------------------------------------------- 1 | ============================================= 2 | Part 1 - Setup Project 3 | ============================================= 4 | 5 | In this tutorial we will build a simple chat server. It will consist of two pages: 6 | 7 | * The room list - consisting of the list of all available chat rooms. 8 | * The chat room - where anyone can go and post a message. 9 | 10 | This tutorial assumes basic knowledge of the Django framework and will use class-based generic views to minimize the amount of code and to focus more on Hotwire. 11 | 12 | Create a virtual environment using a tool of your choice and install ``turbo-django``, along with ``django``, and ``channels``. 13 | 14 | .. code-block:: shell 15 | 16 | $ pip install django turbo-django channels channels_redis 17 | 18 | 19 | Start a django project and create an app called ``chat`` 20 | 21 | .. code-block:: shell 22 | 23 | $ django-admin startproject turbotutorial 24 | $ cd turbotutorial/ 25 | $ ./manage.py startapp chat 26 | 27 | You should now have a set of directories that looks something like: 28 | 29 | .. code-block:: shell 30 | 31 | turbotutorial/ 32 | chat/ 33 | migrations/ 34 | admin.py 35 | apps.py 36 | models.py 37 | tests.py 38 | views.py 39 | turbotutorial/ 40 | asgi.py 41 | settings.py 42 | urls.py 43 | wsgi.py 44 | 45 | 46 | Open ``turbotutorial/settings.py``. 47 | 48 | * Add ``turbo``, ``channels``, and ``chat`` to ``INSTALLED_APPS``. 49 | * Change ``WSGI_APPLICATION = 'turbotutorial.wsgi.application'`` to ``ASGI_APPLICATION = 'turbotutorial.asgi.application'`` 50 | 51 | Your ``settings.py`` file should now look like this. 52 | 53 | .. code-block:: python 54 | 55 | ASGI_APPLICATION = 'turbotutorial.asgi.application' 56 | 57 | 58 | INSTALLED_APPS = [ 59 | 'django.contrib.admin', 60 | 'django.contrib.auth', 61 | 'django.contrib.contenttypes', 62 | 'django.contrib.sessions', 63 | 'django.contrib.messages', 64 | 'django.contrib.staticfiles', 65 | 'turbo', 66 | 'channels', 67 | 'chat' 68 | ] 69 | 70 | CHANNEL_LAYERS = { 71 | "default": { 72 | "BACKEND": "channels_redis.core.RedisChannelLayer", 73 | "CONFIG": { 74 | "hosts": [("127.0.0.1", 6379)], # Set to your local redis host 75 | }, 76 | }, 77 | } 78 | 79 | 80 | 81 | You should now be able to run ``python manage.py runserver``, visit ``http://127.0.0.1:8000/`` and see the standard django startup screen greeting: `The install worked successfully! Congratulations!`. If so, we're ready to :doc:`start coding `. 82 | -------------------------------------------------------------------------------- /doc/source/tutorial/part_2.rst: -------------------------------------------------------------------------------- 1 | ============================================== 2 | Part 2 - Models, Views, and Templates 3 | ============================================== 4 | 5 | Begin by building out the models, views and templates used in the chat application. Nothing is this section is Turbo-specific - that will be introduced in :doc:`the next section `. 6 | 7 | Models 8 | ============== 9 | 10 | This chat application will be set up with two simple models: ``Rooms`` and ``Messages``. Start by creating the models with in ``chat/models.py`` 11 | 12 | .. code-block:: python 13 | :caption: chat/models.py 14 | 15 | from django.db import models 16 | 17 | class Room(models.Model): 18 | name = models.CharField(max_length=255) 19 | 20 | class Message(models.Model): 21 | 22 | room = models.ForeignKey(Room, related_name="messages", on_delete=models.CASCADE) 23 | text = models.CharField(max_length=255) 24 | created_at = models.DateTimeField(auto_now_add=True) 25 | 26 | 27 | 28 | Make a migration and migrate, and then create a test room. 29 | 30 | .. code-block:: shell 31 | 32 | ./manage.py makemigrations 33 | ./manage.py migrate 34 | ./manage.py shell 35 | 36 | 37 | .. code-block:: python 38 | 39 | >>> from chat.models import Room 40 | >>> Room.objects.create(name="Test Room") 41 | 42 | >>> exit() 43 | 44 | 45 | Views and URLs 46 | ================================ 47 | 48 | This tutorial uses generic class-based views to keep the tutorial concise. Add generic `List`, `Detail`, and `Update` views to ``chat/views.py``, and the urls to access them. There is nothing turbo-specific in the following section - we'll be adding that next. 49 | 50 | .. code-block:: python 51 | :caption: chat/views.py 52 | 53 | from django.shortcuts import render, reverse, get_object_or_404 54 | 55 | from django.views.generic import CreateView, ListView, DetailView 56 | 57 | from chat.models import Room, Message 58 | 59 | class RoomList(ListView): 60 | model = Room 61 | context_object_name = "rooms" 62 | 63 | 64 | class RoomDetail(DetailView): 65 | model = Room 66 | context_object_name = "room" 67 | 68 | 69 | class MessageCreate(CreateView): 70 | model = Message 71 | fields = ["text"] 72 | template_name = "chat/components/send_message_form.html" 73 | 74 | def get_success_url(self): 75 | # Redirect to the empty form 76 | return reverse("message_create", kwargs={"pk": self.kwargs["pk"]}) 77 | 78 | def form_valid(self, form): 79 | room = get_object_or_404(Room, pk=self.kwargs["pk"]) 80 | form.instance.room = room 81 | return super().form_valid(form) 82 | 83 | 84 | .. code-block:: python 85 | :caption: turbotutorial/urls.py 86 | 87 | from chat import views 88 | 89 | urlpatterns = [ 90 | path("", views.RoomList.as_view(), name="index"), 91 | path("/", views.RoomDetail.as_view(), name="room_detail"), 92 | path("/message_create", views.MessageCreate.as_view(), name="message_create"), 93 | ] 94 | 95 | 96 | Templates 97 | ========= 98 | 99 | Finally, create the templates for the generic views. 100 | 101 | .. code-block:: html 102 | :caption: turbotutorial/chat/templates/room_list.html 103 | 104 | 105 | 106 | 107 | 108 | Chat Rooms 109 | 110 | 111 |

Room List

112 |
    113 | {% for room in rooms %} 114 |
  • {{ room.name }}
  • 115 | {% empty %} 116 |
  • No Rooms Available
  • 117 | {% endfor %} 118 |
119 | 120 | 121 | 122 | .. code-block:: html 123 | :caption: turbotutorial/chat/templates/room_detail.html 124 | 125 | 126 | 127 | 128 | 129 | Room Detail 130 | 131 | 132 | 133 | Home 134 | 135 |

{{ room.name }}

136 | 137 |
    138 | {% for message in room.messages.all %} 139 |
  • {{message.created_at}}: {{message.text}}
  • 140 | {% endfor %} 141 |
142 | 143 | 144 | 145 | 146 | .. code-block:: html 147 | :caption: turbotutorial/chat/templates/room_form.html 148 | 149 |
150 | {% csrf_token %} 151 | {{ form.as_p }} 152 | 153 |
154 | 155 | Test in your browser to ensure each of the views correctly load. You should be able to get to the `Test Room` detail page from the room list. This application will now display all rooms and messages for each room, but a page refresh is required to see changes. It is time to spice things up and add :doc:`some interactivity ` to this basic app. 156 | 157 | 158 | -------------------------------------------------------------------------------- /doc/source/tutorial/part_3.rst: -------------------------------------------------------------------------------- 1 | =============================== 2 | Part 3 - Your First Turbo Frame 3 | =============================== 4 | 5 | Listen to Turbo Streams 6 | ========================= 7 | 8 | It's time to start creating a dynamic, interactive application. Start by getting Django to listen to websockets by modifying ``asgi.py`` to the following: 9 | 10 | .. code-block:: python 11 | :caption: turbodjango/asgi.py 12 | 13 | import os 14 | 15 | from django.core.asgi import get_asgi_application 16 | from channels.routing import ProtocolTypeRouter 17 | from turbo.consumers import TurboStreamsConsumer 18 | 19 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'turbodjango.settings') 20 | 21 | 22 | application = ProtocolTypeRouter({ 23 | "http": get_asgi_application(), 24 | "websocket": TurboStreamsConsumer.as_asgi() 25 | }) 26 | 27 | 28 | 29 | A Component-Based Mindset 30 | ========================= 31 | 32 | Like many modern JavaScript-based frameworks, it is helpful to start thinking of the webpage as constructed of components. This means breaking down templates into sub-templates with one specific function that are used as the building blocks for each page. 33 | 34 | With that in mind, let's make a `components/` directory for these sub-templates and start work on our first component - a form to create a message in the chat room. 35 | 36 | 37 | .. code-block:: html 38 | :caption: templates/chat/room_detail.html 39 | 40 | 41 | 42 | Room Detail 43 | {% include "turbo/head.html" %} 44 | 45 | 46 | ... 47 | 48 | 49 | 50 | 51 | 52 | 53 | .. code-block:: html 54 | :caption: chat/templates/components/send_message_form.html 55 | 56 | 57 |
58 | {% csrf_token %} 59 | {{ form.as_p }} 60 | 61 |
62 |
63 | 64 | 65 | Introducing the ``turbo-frame`` tag 66 | =================================== 67 | 68 | **** Turbo Frames allow parts of the page to be updated on request. Each turbo-frame must have an id that is shared between the parent frame, and the elements that will be loaded into the frame. 69 | 70 | .. note:: 71 | Be sure to read the `official documentation of Turbo Frames `_. 72 | 73 | 74 | Run the code and test. When text is submitted in the text box, the box is cleared and ready for new entry. Let's walk through what is happening: 75 | 76 | * In `room_detail.html`, the turbo-frame makes a new request to the url specified in the ``src`` attribute. 77 | * Turbo looks for a turbo-frame with the same id in the response and inserts the content into the parent frame. The form is now displayed in the page. 78 | * The user types content into the form and hits submit. 79 | * The framed form is submitted without the page reloading. The ``get_success_url()`` method returns a response equivilent to the url ``{% url 'message_create_form' room.id %}`` -- the same ``src`` of the parent turbo frame - loading a new blank form which is inserted into the frame. 80 | 81 | Refreshing the page renders the submitted messages. But for a chat client to be useful, those messages need to appear immediately on the page, and other pages that have this url open. For that, we use :doc:`turbo streams `. 82 | 83 | -------------------------------------------------------------------------------- /doc/source/tutorial/part_4.rst: -------------------------------------------------------------------------------- 1 | ======================================== 2 | Part 4 - Pushing data with Turbo Streams 3 | ======================================== 4 | 5 | Turbo Streams 6 | ============= 7 | 8 | Turbo Streams allow HTML to be pushed to the client page without a request from the client. The client needs to subscribe to a stream and this is done with the :doc:`turbo_subscribe tag `. 9 | 10 | Add the following: 11 | 12 | .. code-block:: html 13 | :caption: templates/chat/room_detail.html 14 | 15 | {% load turbo_streams %} 16 | {% turbo_subscribe room %} 17 | 18 | * ``load turbo_streams`` allows use of the ``turbo_subscribe`` tag. 19 | * ``turbo_subscribe`` subscribes to the Room instance stream. 20 | 21 | Since we want message lines to be dynamic, every message line must now be turned into a component. Replace the message loop with: 22 | 23 | 24 | .. code-block:: html 25 | :caption: templates/chat/room_detail.html 26 | 27 | {% for message in room.messages.all %} 28 | {% include "chat/components/message.html" with message=message only %} 29 | {% endfor %} 30 | 31 | .. code-block:: html 32 | :caption: templates/chat/components/message.html 33 | 34 |
  • {{message.created_at}}: {{message.text}}
  • 35 | 36 | 37 | The model now needs to send a rendered message to stream subscribers on each save. Turbo-django makes this a breeze. 38 | 39 | Create a new file in the chat application called ``streams.py`` 40 | 41 | 42 | .. code-block:: python 43 | :caption: chat/streams.py 44 | 45 | import turbo 46 | from .models import Message 47 | 48 | class RoomStream(turbo.ModelStream): 49 | class Meta: 50 | model = Room 51 | 52 | 53 | class MessageStream(turbo.ModelStream): 54 | 55 | class Meta: 56 | model = Message 57 | 58 | def on_save(self, message, created, *args, **kwargs): 59 | if created: 60 | message.room.stream.append( 61 | "chat/components/message.html", {"message": message}, id="messages" 62 | ) 63 | 64 | 65 | The file ``streams.py`` is automatically detected by the Turbo Django library and is the recommended location for all stream-related code. In this example, a ``ModelStream`` is created, which is a type of stream attached to a model instance. The model to tie the stream to is defined in the Meta class. This attaches a ``.stream`` TurboStream attribute to the instance. Now the instance can be subscribed to, and have data streamed to those subscribers. 66 | 67 | In this example, the chat room is what is being subscribed to, but the message is the model being saved - so we create both ModelStreams, and in the Message's ``on_save`` signal, we call on the parent room's stream to append a new message component. 68 | 69 | Run this code and see it work in the browser. Now open up a new window and see how the pages update each other. 70 | 71 | Congratulations! You have created a basic chat application. In the :doc:`next tutorial `, we'll add even more functionality. 72 | -------------------------------------------------------------------------------- /doc/source/tutorial/part_5.rst: -------------------------------------------------------------------------------- 1 | ===================================== 2 | Part 5 - More Fun with Broadcasts 3 | ===================================== 4 | 5 | Removing Chat Messages 6 | ====================== 7 | 8 | Let's continue to build on this basic chat application by allowing the user to remove messages. Let's walk through the steps to accomplish this: 9 | 10 | * Add links to the `message` component template to remove the message. 11 | * Add a ``message_id`` to each list item so Turbo knows which message to delete. 12 | * Create a view that deletes the message. 13 | * Add an ``on_delete`` method to the ModelStream. Tell the room subscribers to remove the message using the html id value. 14 | 15 | 16 | Start by adding a unique id to each ``
  • `` element. Then add a link to remove that message in the template. 17 | 18 | .. code-block:: html 19 | :caption: templates/chat/components/message.html 20 | 21 |
  • 22 | {{message.created_at}}: {{message.text}} 23 | [Remove] 24 |
  • 25 | 26 | 27 | As this link is outside a turbo-frame, this delete link will replace the contents of the entire page. To only send a request to the ``message_delete`` url, the links need to be inside a turbo-frame. Wrap the `
      ` element in ``room_detail.html`` inside a turbo frame. 28 | 29 | .. code-block:: html 30 | :caption: templates/chat/room_detail.html 31 | :emphasize-lines: 1,7 32 | 33 | 34 |
        35 | {% for message in room.messages.all%} 36 | {% include "chat/components/message.html" with message=message only %} 37 | {% endfor %} 38 |
      39 |
      40 | 41 | 42 | 43 | Add the `message_delete` url and view. 44 | 45 | .. code-block:: python 46 | :caption: turbotutorial/urls.py 47 | 48 | urlpatterns = [ 49 | ... 50 | path("message//delete", views.message_delete, name="message_delete"), 51 | ] 52 | 53 | .. code-block:: python 54 | :caption: chat/views.py 55 | 56 | from django.http import HttpResponse 57 | 58 | ... 59 | 60 | def message_delete(request, message_id): 61 | message = get_object_or_404(Message, pk=message_id) 62 | message.delete() 63 | return HttpResponse() 64 | 65 | And finally, broadcast to clients subscribed to the message's room to remove any item on the page with the unique id specified in the template. 66 | 67 | .. code-block:: python 68 | :caption: chat/broadcasts.py 69 | 70 | class MessageStream(turbo.ModelStream): 71 | 72 | ... 73 | 74 | def on_delete(self, message, *args, **kwargs): 75 | message.room.stream.remove(id=f"message-{message.id}") 76 | 77 | 78 | .. note:: 79 | Notice that ``.remove()`` is used without first calling ``.render()``. Remove only takes away content, so rendering a template is not necessary. 80 | 81 | -------------------------------------------------------------------------------- /experiments/chat/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Davis Haupt 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 4 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 5 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit 6 | persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 9 | Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 12 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 13 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 14 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | -------------------------------------------------------------------------------- /experiments/chat/README.md: -------------------------------------------------------------------------------- 1 | # Django Hotwire Demo 2 | 3 | This repository contains a demonstration of [Hotwire](https://hotwired.dev), specifically the three components of 4 | [Turbo](https://turbo.hotwired.dev) to build a realtime chat app in Django with only server-side custom code. It makes use 5 | of Django Channels for websocket support. 6 | 7 | To run this demo, after cloning the repository: 8 | 9 | ```bash 10 | cd experiments/chat 11 | python3 -m venv venv 12 | source venv/bin/activate 13 | pip install -e ../../ 14 | pip install -r requirements.txt 15 | 16 | ./manage.py migrate 17 | ./manage.py createsuperuser 18 | ./manage.py loaddata data.json 19 | ./manage.py runserver 20 | ``` 21 | 22 | Go to `http://localhost:8000`, select your room, and start chatting! Open as many windows as you'd like. 23 | -------------------------------------------------------------------------------- /experiments/chat/chat/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hotwire-django/turbo-django/185baaec7941f2abb09c90624c7678ee6260df03/experiments/chat/chat/__init__.py -------------------------------------------------------------------------------- /experiments/chat/chat/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ChatConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'chat' 7 | -------------------------------------------------------------------------------- /experiments/chat/chat/forms.py: -------------------------------------------------------------------------------- 1 | from chat.models import Room 2 | from django import forms 3 | 4 | 5 | class RoomForm(forms.ModelForm): 6 | 7 | name = forms.CharField(label="Room Name", required=False) 8 | 9 | class Meta: 10 | model = Room 11 | fields = ('name',) 12 | -------------------------------------------------------------------------------- /experiments/chat/chat/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.5 on 2021-08-02 12:54 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='Room', 17 | fields=[ 18 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('name', models.CharField(max_length=255)), 20 | ], 21 | ), 22 | migrations.CreateModel( 23 | name='Message', 24 | fields=[ 25 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 26 | ('text', models.TextField()), 27 | ('created_at', models.DateTimeField(auto_now_add=True)), 28 | ('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='chat.room')), 29 | ], 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /experiments/chat/chat/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hotwire-django/turbo-django/185baaec7941f2abb09c90624c7678ee6260df03/experiments/chat/chat/migrations/__init__.py -------------------------------------------------------------------------------- /experiments/chat/chat/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Room(models.Model): 5 | name = models.CharField(max_length=255) 6 | 7 | 8 | class Message(models.Model): 9 | 10 | room = models.ForeignKey(Room, related_name="messages", on_delete=models.CASCADE) 11 | text = models.CharField(max_length=255) 12 | created_at = models.DateTimeField(auto_now_add=True) 13 | -------------------------------------------------------------------------------- /experiments/chat/chat/streams.py: -------------------------------------------------------------------------------- 1 | from .models import Message, Room 2 | 3 | import turbo 4 | from turbo.shortcuts import render_frame 5 | 6 | 7 | class RoomListStream(turbo.Stream): 8 | def add_room(self, room): 9 | self.append("chat/components/room_list_item.html", {"room": room}, id="room_list") 10 | 11 | def delete_all(self): 12 | self.remove(selector="#room_list li") 13 | 14 | 15 | class RoomStream(turbo.ModelStream): 16 | class Meta: 17 | model = Room 18 | 19 | def on_save(self, room, created, *args, **kwargs): 20 | 21 | if created: 22 | # room.turbo.render("chat/components/room_name.html", {"room": room}).append(id="room-list") 23 | RoomListStream().add_room(room) 24 | else: 25 | pass 26 | # room.turbo.render("chat/room_name.html", {"room": room}).replace(id="update-room") 27 | # room.turbo.render("chat/room.html", {}).append(id="rooms") 28 | 29 | def user_passes_test(self, user): 30 | return True 31 | 32 | 33 | class MessageStream(turbo.ModelStream): 34 | class Meta: 35 | model = Message 36 | 37 | def on_save(self, message, created, *args, **kwargs): 38 | if created: 39 | message.room.stream.append( 40 | "chat/components/message.html", {"message": message}, id="messages" 41 | ) 42 | else: 43 | message.room.stream.replace( 44 | "chat/components/message.html", {"message": message}, id=f"message-{message.id}" 45 | ) 46 | 47 | def on_delete(self, message, *args, **kwargs): 48 | message.room.stream.remove(id=f"message-{message.id}") 49 | 50 | def user_passes_test(self, user): 51 | return True 52 | -------------------------------------------------------------------------------- /experiments/chat/chat/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | Chat Turbo Demo App 6 | 7 | 8 | 9 | 10 | {% include "turbo/head.html" %} 11 | 12 | {% block style %}{% endblock style %} 13 | 14 | 15 |
      16 | 17 |
      18 | 19 | {% block navbar %} 20 | 21 | 28 | 29 | {% endblock navbar %} 30 | 31 |
      32 | {% block body %} 33 | BODY 34 | {% endblock body %} 35 |
      36 | 37 |
      38 | 39 |
      40 |
      41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | {% block script %} 49 | {% endblock %} 50 | 51 | 52 | -------------------------------------------------------------------------------- /experiments/chat/chat/templates/chat/components/create_room_form.html: -------------------------------------------------------------------------------- 1 | 2 |
      3 | {% csrf_token %} 4 | {{ form.as_p }} 5 | 6 | 7 |
      8 |
      9 | 10 | -------------------------------------------------------------------------------- /experiments/chat/chat/templates/chat/components/message.html: -------------------------------------------------------------------------------- 1 |
    • {{message.created_at}}: {{message.text}} [Remove]
    • 2 | -------------------------------------------------------------------------------- /experiments/chat/chat/templates/chat/components/room_list_item.html: -------------------------------------------------------------------------------- 1 |
    • {{ room.name }}
    • 2 | -------------------------------------------------------------------------------- /experiments/chat/chat/templates/chat/components/send_message_form.html: -------------------------------------------------------------------------------- 1 | 2 |
      3 | {% csrf_token %} 4 | {{ form.as_p }} 5 | 6 |
      7 |
      8 | -------------------------------------------------------------------------------- /experiments/chat/chat/templates/chat/room_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load turbo_streams %} 3 | 4 | {% block body %} 5 | 6 | {% turbo_subscribe room %} 7 | 8 | Home 9 | 10 |

      {{ room.name }}

      11 | 12 | 13 |
        14 | {% for message in room.messages.all %} 15 | {% include "chat/components/message.html" with message=message only %} 16 | {% endfor %} 17 |
      18 |
      19 | 20 | 21 | 22 | {% endblock body %} 23 | -------------------------------------------------------------------------------- /experiments/chat/chat/templates/chat/room_form.html: -------------------------------------------------------------------------------- 1 |
      2 | {% csrf_token %} 3 | {{ form.as_p }} 4 | 5 |
      6 | -------------------------------------------------------------------------------- /experiments/chat/chat/templates/chat/room_list.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load turbo_streams %} 3 | {% block body %} 4 | 5 | {% turbo_subscribe "chat:RoomListStream" %} 6 | 7 |

      Room List

      8 |
        9 | {% for room in rooms %} 10 | {% include "chat/components/room_list_item.html" %} 11 | {% endfor %} 12 |
      13 | 14 | {% include "chat/components/create_room_form.html" %} 15 | 16 | {% endblock body %} 17 | -------------------------------------------------------------------------------- /experiments/chat/chat/views.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from django.shortcuts import reverse, get_object_or_404 4 | from django.http import HttpResponse 5 | from django.views.generic import CreateView, ListView, DetailView 6 | 7 | from chat.models import Room, Message 8 | from chat.streams import RoomListStream 9 | from chat.forms import RoomForm 10 | 11 | from turbo.shortcuts import render_frame, remove_frame 12 | 13 | 14 | class RoomList(ListView): 15 | model = Room 16 | context_object_name = "rooms" 17 | 18 | def get_context_data(self, **kwargs): 19 | context = super().get_context_data(**kwargs) 20 | context['form'] = RoomForm() 21 | return context 22 | 23 | def post(self, request, *args, **kwargs): 24 | 25 | action = request.POST.get('action') 26 | if action == "Delete All": 27 | Room.objects.all().delete() 28 | RoomListStream().delete_all() 29 | return HttpResponse() 30 | 31 | form = RoomForm(request.POST) 32 | form.save() 33 | 34 | new_form = RoomForm() 35 | 36 | return ( 37 | render_frame( 38 | request, 39 | "chat/components/create_room_form.html", 40 | {"form": new_form}, 41 | ) 42 | .replace(id="create-room-form") 43 | .response 44 | ) 45 | 46 | 47 | class RoomDetail(DetailView): 48 | model = Room 49 | context_object_name = "room" 50 | 51 | 52 | class MessageCreate(CreateView): 53 | model = Message 54 | fields = ["text"] 55 | template_name = "chat/components/send_message_form.html" 56 | 57 | def get_success_url(self): 58 | # Redirect to the empty form 59 | return reverse("message_create", kwargs={"pk": self.kwargs["pk"]}) 60 | 61 | def form_valid(self, form): 62 | room = get_object_or_404(Room, pk=self.kwargs["pk"]) 63 | form.instance.room = room 64 | return super().form_valid(form) 65 | 66 | 67 | def message_delete(request, message_id): 68 | message = get_object_or_404(Message, pk=message_id) 69 | message.delete() 70 | return HttpResponse() 71 | -------------------------------------------------------------------------------- /experiments/chat/data.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "model": "chat.room", "pk": 1, "fields": { "name": "Demo Room" } }, 3 | { 4 | "model": "chat.message", 5 | "pk": 1, 6 | "fields": { 7 | "room": 1, 8 | "text": "demo message", 9 | "created_at": "2021-01-01T01:33:57.374Z" 10 | } 11 | } 12 | ] 13 | -------------------------------------------------------------------------------- /experiments/chat/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'turbotutorial.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /experiments/chat/requirements.txt: -------------------------------------------------------------------------------- 1 | channels 2 | asgiref 3 | django 4 | 5 | # Dev deps 6 | pytest 7 | pytest-django 8 | -------------------------------------------------------------------------------- /experiments/chat/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hotwire-django/turbo-django/185baaec7941f2abb09c90624c7678ee6260df03/experiments/chat/test/__init__.py -------------------------------------------------------------------------------- /experiments/chat/test/context.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..'))) 5 | -------------------------------------------------------------------------------- /experiments/chat/test/test_basic.py: -------------------------------------------------------------------------------- 1 | from . import context 2 | import turbo 3 | 4 | 5 | def test_tests(): 6 | assert True 7 | 8 | 9 | def test_import(): 10 | assert turbo.default_app_config == "turbo.apps.TurboDjangoConfig" 11 | -------------------------------------------------------------------------------- /experiments/chat/turbotutorial/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hotwire-django/turbo-django/185baaec7941f2abb09c90624c7678ee6260df03/experiments/chat/turbotutorial/__init__.py -------------------------------------------------------------------------------- /experiments/chat/turbotutorial/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for turbotutorial project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | from channels.routing import ProtocolTypeRouter 14 | from turbo.consumers import TurboStreamsConsumer 15 | 16 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'turbodjango.settings') 17 | 18 | 19 | application = ProtocolTypeRouter( 20 | { 21 | "http": get_asgi_application(), 22 | "websocket": TurboStreamsConsumer.as_asgi(), # Leave off .as_asgi() if using Channels 2.x 23 | } 24 | ) 25 | -------------------------------------------------------------------------------- /experiments/chat/turbotutorial/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for turbotutorial project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.2.5. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.2/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'django-insecure-k-zmgkm*7x_-&&s=g87(2dxw%l=v$s@py28sw88m@qm#it*y=q' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 'turbo', 41 | 'channels', 42 | 'chat', 43 | ] 44 | 45 | MIDDLEWARE = [ 46 | 'django.middleware.security.SecurityMiddleware', 47 | 'django.contrib.sessions.middleware.SessionMiddleware', 48 | 'django.middleware.common.CommonMiddleware', 49 | 'django.middleware.csrf.CsrfViewMiddleware', 50 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 51 | 'django.contrib.messages.middleware.MessageMiddleware', 52 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 53 | ] 54 | 55 | ROOT_URLCONF = 'turbotutorial.urls' 56 | 57 | TEMPLATES = [ 58 | { 59 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 60 | 'DIRS': [], 61 | 'APP_DIRS': True, 62 | 'OPTIONS': { 63 | 'context_processors': [ 64 | 'django.template.context_processors.debug', 65 | 'django.template.context_processors.request', 66 | 'django.contrib.auth.context_processors.auth', 67 | 'django.contrib.messages.context_processors.messages', 68 | ] 69 | }, 70 | } 71 | ] 72 | 73 | # WSGI_APPLICATION = 'turbotutorial.wsgi.application' 74 | ASGI_APPLICATION = 'turbotutorial.asgi.application' 75 | 76 | 77 | # Database 78 | # https://docs.djangoproject.com/en/3.2/ref/settings/#databases 79 | 80 | DATABASES = {'default': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3'}} 81 | 82 | CHANNEL_LAYERS = { 83 | "default": { 84 | # Don't use this backend in production 85 | # See https://channels.readthedocs.io/en/latest/topics/channel_layers.html 86 | "BACKEND": "channels.layers.InMemoryChannelLayer" 87 | } 88 | } 89 | 90 | 91 | # Password validation 92 | # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators 93 | 94 | AUTH_PASSWORD_VALIDATORS = [ 95 | {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, 96 | {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, 97 | {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, 98 | {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, 99 | ] 100 | 101 | 102 | # Internationalization 103 | # https://docs.djangoproject.com/en/3.2/topics/i18n/ 104 | 105 | LANGUAGE_CODE = 'en-us' 106 | 107 | TIME_ZONE = 'UTC' 108 | 109 | USE_I18N = True 110 | 111 | USE_L10N = True 112 | 113 | USE_TZ = True 114 | 115 | 116 | # Static files (CSS, JavaScript, Images) 117 | # https://docs.djangoproject.com/en/3.2/howto/static-files/ 118 | 119 | STATIC_URL = '/static/' 120 | STATIC_ROOT = '' 121 | 122 | # Default primary key field type 123 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field 124 | 125 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 126 | -------------------------------------------------------------------------------- /experiments/chat/turbotutorial/urls.py: -------------------------------------------------------------------------------- 1 | """turbotutorial URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.urls import path 17 | from django.views.generic import TemplateView 18 | 19 | from chat import views 20 | 21 | urlpatterns = [ 22 | path("", views.RoomList.as_view(), name="room_list"), 23 | path("/", views.RoomDetail.as_view(), name="room_detail"), 24 | path("/message_create", views.MessageCreate.as_view(), name="message_create"), 25 | path("message//delete", views.message_delete, name="message_delete"), 26 | ] 27 | -------------------------------------------------------------------------------- /experiments/chat/turbotutorial/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for turbotutorial project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'turbotutorial.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /experiments/components_demo/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Stephen Mitchell 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 4 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 5 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit 6 | persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 9 | Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 12 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 13 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 14 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | -------------------------------------------------------------------------------- /experiments/components_demo/README.md: -------------------------------------------------------------------------------- 1 | # Django Hotwire Demo - Reminders 2 | 3 | This repository contains a demonstration of [Hotwire](https://hotwired.dev), and 4 | how a site can be built using turbo-frames. 5 | 6 | To run this demo, after cloning the repository: 7 | 8 | ```bash 9 | cd experiments/reminders 10 | python3 -m venv venv 11 | source venv/bin/activate 12 | pip install -e ../../ 13 | pip install -r requirements.txt 14 | 15 | ./manage.py migrate 16 | ./manage.py createsuperuser 17 | ./manage.py runserver 18 | ``` 19 | 20 | Go to `http://localhost:8000`, and start adding reminders. 21 | -------------------------------------------------------------------------------- /experiments/components_demo/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hotwire-django/turbo-django/185baaec7941f2abb09c90624c7678ee6260df03/experiments/components_demo/app/__init__.py -------------------------------------------------------------------------------- /experiments/components_demo/app/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DemoConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'app' 7 | -------------------------------------------------------------------------------- /experiments/components_demo/app/streams.py: -------------------------------------------------------------------------------- 1 | from turbo.components import BroadcastComponent, UserBroadcastComponent 2 | 3 | 4 | class AlertBroadcastComponent(BroadcastComponent): 5 | template_name = "app/components/sample_broadcast_component.html" 6 | 7 | 8 | class CartCountComponent(UserBroadcastComponent): 9 | template_name = "app/components/cart_count_component.html" 10 | 11 | def get_context(self): 12 | return {"count": 99} # user.cart.items_in_cart 13 | -------------------------------------------------------------------------------- /experiments/components_demo/app/templates/app/components/cart_count_component.html: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /experiments/components_demo/app/templates/app/components/sample_broadcast_component.html: -------------------------------------------------------------------------------- 1 | {% if alert_content %} 2 | 5 | {% endif %} 6 | -------------------------------------------------------------------------------- /experiments/components_demo/app/templates/app/home.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load turbo_streams %} 3 | 4 | 5 | {% block body %} 6 |

      Components Demo

      7 |

      This application demonstrates a use of Turbo Components.

      8 | 9 |
      10 |

      BroadcastComponent

      11 |
      12 | # From `./manage.py shell` execute the following commands:
      13 | from app.streams import AlertBroadcastComponent
      14 | a = AlertBroadcastComponent()
      15 | a.render(alert_class='warning', alert_content="sasdasd")
      16 | {% turbo_component "app:AlertBroadcastComponent" %} 17 |
      18 | 19 |
      20 |

      UserBroadcastComponent

      21 |
      22 | A user must be created and logged in to test this component.
      23 | ## From `./manage.py shell` execute the following commands:
      24 | from app.streams importCartCountComponent
      25 | c = CartCountComponent(user)
      26 | c.render(count=25)
      27 | {% turbo_component "app:CartCountComponent" request.user %} 28 |
      29 | 30 | {% endblock body %} 31 | 32 | -------------------------------------------------------------------------------- /experiments/components_demo/app/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | Reminders Turbo Demo App 6 | 7 | 8 | 9 | 10 | {% include "turbo/head.html" %} 11 | 12 | {% block style %}{% endblock style %} 13 | 14 | 15 |
      16 | 17 |
      18 | 19 | {% block navbar %} 20 | 21 | 28 | 29 | {% endblock navbar %} 30 | 31 |
      32 | {% block body %} 33 | BODY 34 | {% endblock body %} 35 |
      36 | 37 |
      38 | 39 |
      40 |
      41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | {% block script %} 49 | {% endblock %} 50 | 51 | 52 | -------------------------------------------------------------------------------- /experiments/components_demo/app/views.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from django.shortcuts import render 3 | from turbo.shortcuts import render_frame, remove_frame 4 | 5 | from .streams import * 6 | 7 | 8 | def components_demo_view(request): 9 | 10 | return render(request, "app/home.html", {}) 11 | -------------------------------------------------------------------------------- /experiments/components_demo/components_demo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hotwire-django/turbo-django/185baaec7941f2abb09c90624c7678ee6260df03/experiments/components_demo/components_demo/__init__.py -------------------------------------------------------------------------------- /experiments/components_demo/components_demo/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for turbotutorial project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | from channels.routing import ProtocolTypeRouter 14 | from turbo.consumers import TurboStreamsConsumer 15 | 16 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'components_demo.settings') 17 | 18 | 19 | application = ProtocolTypeRouter( 20 | { 21 | "http": get_asgi_application(), 22 | "websocket": TurboStreamsConsumer.as_asgi(), # Leave off .as_asgi() if using Channels 2.x 23 | } 24 | ) 25 | -------------------------------------------------------------------------------- /experiments/components_demo/components_demo/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for turbotutorial project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.2.5. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.2/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'django-insecure-k-zmgkm*7x_-&&s=g87(2dxw%l=v$s@py28sw88m@qm#it*y=q' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 'turbo', 41 | 'channels', 42 | 'app', 43 | ] 44 | 45 | MIDDLEWARE = [ 46 | 'django.middleware.security.SecurityMiddleware', 47 | 'django.contrib.sessions.middleware.SessionMiddleware', 48 | 'django.middleware.common.CommonMiddleware', 49 | 'django.middleware.csrf.CsrfViewMiddleware', 50 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 51 | 'django.contrib.messages.middleware.MessageMiddleware', 52 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 53 | ] 54 | 55 | ROOT_URLCONF = 'components_demo.urls' 56 | 57 | TEMPLATES = [ 58 | { 59 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 60 | 'APP_DIRS': True, 61 | 'OPTIONS': { 62 | 'context_processors': [ 63 | 'django.template.context_processors.debug', 64 | 'django.template.context_processors.request', 65 | 'django.contrib.auth.context_processors.auth', 66 | 'django.contrib.messages.context_processors.messages', 67 | ] 68 | }, 69 | } 70 | ] 71 | 72 | # WSGI_APPLICATION = 'components_demo.wsgi.application' 73 | ASGI_APPLICATION = 'components_demo.asgi.application' 74 | 75 | 76 | # Database 77 | # https://docs.djangoproject.com/en/3.2/ref/settings/#databases 78 | 79 | DATABASES = {'default': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3'}} 80 | 81 | CHANNEL_LAYERS = { 82 | "default": { 83 | "BACKEND": "channels_redis.core.RedisChannelLayer", 84 | "CONFIG": { 85 | "hosts": [("127.0.0.1", 6379)], 86 | }, 87 | }, 88 | } 89 | 90 | # Password validation 91 | # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators 92 | 93 | AUTH_PASSWORD_VALIDATORS = [ 94 | {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, 95 | {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, 96 | {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, 97 | {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, 98 | ] 99 | 100 | 101 | # Internationalization 102 | # https://docs.djangoproject.com/en/3.2/topics/i18n/ 103 | 104 | LANGUAGE_CODE = 'en-us' 105 | 106 | TIME_ZONE = 'UTC' 107 | 108 | USE_I18N = True 109 | 110 | USE_L10N = True 111 | 112 | USE_TZ = True 113 | 114 | 115 | # Static files (CSS, JavaScript, Images) 116 | # https://docs.djangoproject.com/en/3.2/howto/static-files/ 117 | 118 | STATIC_URL = '/static/' 119 | STATIC_ROOT = '' 120 | 121 | # Default primary key field type 122 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field 123 | 124 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 125 | -------------------------------------------------------------------------------- /experiments/components_demo/components_demo/urls.py: -------------------------------------------------------------------------------- 1 | """turbotutorial URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.urls import path 17 | from django.views.generic import TemplateView 18 | 19 | from app import views 20 | 21 | urlpatterns = [ 22 | path('', views.components_demo_view, name='home'), 23 | ] 24 | -------------------------------------------------------------------------------- /experiments/components_demo/components_demo/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for turbotutorial project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'components_demo.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /experiments/components_demo/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'components_demo.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /experiments/components_demo/requirements.txt: -------------------------------------------------------------------------------- 1 | channels 2 | asgiref 3 | django 4 | 5 | # Dev deps 6 | pytest 7 | pytest-django 8 | -------------------------------------------------------------------------------- /experiments/components_demo/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hotwire-django/turbo-django/185baaec7941f2abb09c90624c7678ee6260df03/experiments/components_demo/test/__init__.py -------------------------------------------------------------------------------- /experiments/components_demo/test/context.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..'))) 5 | -------------------------------------------------------------------------------- /experiments/components_demo/test/test_basic.py: -------------------------------------------------------------------------------- 1 | from . import context 2 | import turbo 3 | 4 | 5 | def test_tests(): 6 | assert True 7 | 8 | 9 | def test_import(): 10 | assert turbo.default_app_config == "turbo.apps.TurboDjangoConfig" 11 | -------------------------------------------------------------------------------- /experiments/quickstart/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Stephen Mitchell 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 4 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 5 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit 6 | persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 9 | Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 12 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 13 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 14 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | -------------------------------------------------------------------------------- /experiments/quickstart/README.md: -------------------------------------------------------------------------------- 1 | # Django Hotwire Demo - Reminders 2 | 3 | This repository contains a demonstration of [Hotwire](https://hotwired.dev), and 4 | how a site can be built using turbo-frames. 5 | 6 | To run this demo, after cloning the repository: 7 | 8 | ```bash 9 | cd experiments/reminders 10 | python3 -m venv venv 11 | source venv/bin/activate 12 | pip install -e ../../ 13 | pip install -r requirements.txt 14 | 15 | ./manage.py migrate 16 | ./manage.py createsuperuser 17 | ./manage.py runserver 18 | ``` 19 | 20 | Go to `http://localhost:8000`, and start adding reminders. 21 | -------------------------------------------------------------------------------- /experiments/quickstart/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'turbotutorial.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /experiments/quickstart/quickstart/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hotwire-django/turbo-django/185baaec7941f2abb09c90624c7678ee6260df03/experiments/quickstart/quickstart/__init__.py -------------------------------------------------------------------------------- /experiments/quickstart/quickstart/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class QuickStartConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'quickstart' 7 | -------------------------------------------------------------------------------- /experiments/quickstart/quickstart/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0 on 2022-02-19 20:36 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Reminder', 16 | fields=[ 17 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('reminder_text', models.CharField(max_length=255)), 19 | ('completed_date', models.DateTimeField(blank=True, null=True)), 20 | ('order', models.IntegerField(default=0)), 21 | ], 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /experiments/quickstart/quickstart/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hotwire-django/turbo-django/185baaec7941f2abb09c90624c7678ee6260df03/experiments/quickstart/quickstart/migrations/__init__.py -------------------------------------------------------------------------------- /experiments/quickstart/quickstart/streams.py: -------------------------------------------------------------------------------- 1 | import turbo 2 | 3 | 4 | class BroadcastStream(turbo.Stream): 5 | pass 6 | -------------------------------------------------------------------------------- /experiments/quickstart/quickstart/templates/broadcast_example.html: -------------------------------------------------------------------------------- 1 | {% load turbo_streams %} 2 | 3 | 4 | 5 | {% include "turbo/head.html" %} 6 | 7 | 8 | {% turbo_subscribe 'quickstart:BroadcastStream' %} 9 |

      Broadcast Quickstart

      10 |

      Placeholder for broadcast

      11 | 12 | 13 | -------------------------------------------------------------------------------- /experiments/quickstart/requirements.txt: -------------------------------------------------------------------------------- 1 | channels 2 | asgiref 3 | django 4 | 5 | # Dev deps 6 | pytest 7 | pytest-django 8 | -------------------------------------------------------------------------------- /experiments/quickstart/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hotwire-django/turbo-django/185baaec7941f2abb09c90624c7678ee6260df03/experiments/quickstart/test/__init__.py -------------------------------------------------------------------------------- /experiments/quickstart/test/context.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..'))) 5 | -------------------------------------------------------------------------------- /experiments/quickstart/test/test_basic.py: -------------------------------------------------------------------------------- 1 | from . import context 2 | import turbo 3 | 4 | 5 | def test_tests(): 6 | assert True 7 | 8 | 9 | def test_import(): 10 | assert turbo.default_app_config == "turbo.apps.TurboDjangoConfig" 11 | -------------------------------------------------------------------------------- /experiments/quickstart/turbotutorial/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hotwire-django/turbo-django/185baaec7941f2abb09c90624c7678ee6260df03/experiments/quickstart/turbotutorial/__init__.py -------------------------------------------------------------------------------- /experiments/quickstart/turbotutorial/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for turbotutorial project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | from channels.routing import ProtocolTypeRouter 14 | from turbo.consumers import TurboStreamsConsumer 15 | 16 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'turbodjango.settings') 17 | 18 | 19 | application = ProtocolTypeRouter( 20 | { 21 | "http": get_asgi_application(), 22 | "websocket": TurboStreamsConsumer.as_asgi(), # Leave off .as_asgi() if using Channels 2.x 23 | } 24 | ) 25 | -------------------------------------------------------------------------------- /experiments/quickstart/turbotutorial/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for turbotutorial project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.2.5. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.2/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'django-insecure-k-zmgkm*7x_-&&s=g87(2dxw%l=v$s@py28sw88m@qm#it*y=q' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 'turbo', 41 | 'channels', 42 | 'quickstart', 43 | ] 44 | 45 | MIDDLEWARE = [ 46 | 'django.middleware.security.SecurityMiddleware', 47 | 'django.contrib.sessions.middleware.SessionMiddleware', 48 | 'django.middleware.common.CommonMiddleware', 49 | 'django.middleware.csrf.CsrfViewMiddleware', 50 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 51 | 'django.contrib.messages.middleware.MessageMiddleware', 52 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 53 | ] 54 | 55 | ROOT_URLCONF = 'turbotutorial.urls' 56 | 57 | TEMPLATES = [ 58 | { 59 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 60 | 'DIRS': [], 61 | 'APP_DIRS': True, 62 | 'OPTIONS': { 63 | 'context_processors': [ 64 | 'django.template.context_processors.debug', 65 | 'django.template.context_processors.request', 66 | 'django.contrib.auth.context_processors.auth', 67 | 'django.contrib.messages.context_processors.messages', 68 | ] 69 | }, 70 | } 71 | ] 72 | 73 | # WSGI_APPLICATION = 'turbotutorial.wsgi.application' 74 | ASGI_APPLICATION = 'turbotutorial.asgi.application' 75 | 76 | 77 | # Database 78 | # https://docs.djangoproject.com/en/3.2/ref/settings/#databases 79 | 80 | DATABASES = {'default': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3'}} 81 | 82 | CHANNEL_LAYERS = { 83 | "default": { 84 | "BACKEND": "channels_redis.core.RedisChannelLayer", 85 | "CONFIG": { 86 | "hosts": [("127.0.0.1", 6379)], 87 | }, 88 | }, 89 | } 90 | 91 | # Password validation 92 | # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators 93 | 94 | AUTH_PASSWORD_VALIDATORS = [ 95 | {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, 96 | {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, 97 | {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, 98 | {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, 99 | ] 100 | 101 | 102 | # Internationalization 103 | # https://docs.djangoproject.com/en/3.2/topics/i18n/ 104 | 105 | LANGUAGE_CODE = 'en-us' 106 | 107 | TIME_ZONE = 'UTC' 108 | 109 | USE_I18N = True 110 | 111 | USE_L10N = True 112 | 113 | USE_TZ = True 114 | 115 | 116 | # Static files (CSS, JavaScript, Images) 117 | # https://docs.djangoproject.com/en/3.2/howto/static-files/ 118 | 119 | STATIC_URL = '/static/' 120 | STATIC_ROOT = '' 121 | 122 | # Default primary key field type 123 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field 124 | 125 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 126 | -------------------------------------------------------------------------------- /experiments/quickstart/turbotutorial/urls.py: -------------------------------------------------------------------------------- 1 | """turbotutorial URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.urls import path 17 | from django.views.generic import TemplateView 18 | 19 | urlpatterns = [path('', TemplateView.as_view(template_name='broadcast_example.html'))] 20 | -------------------------------------------------------------------------------- /experiments/quickstart/turbotutorial/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for turbotutorial project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'turbotutorial.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /experiments/reminders/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Stephen Mitchell 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 4 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 5 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit 6 | persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 9 | Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 12 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 13 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 14 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | -------------------------------------------------------------------------------- /experiments/reminders/README.md: -------------------------------------------------------------------------------- 1 | # Django Hotwire Demo - Reminders 2 | 3 | This repository contains a demonstration of [Hotwire](https://hotwired.dev), and 4 | how a site can be built using turbo-frames. 5 | 6 | To run this demo, after cloning the repository: 7 | 8 | ```bash 9 | cd experiments/reminders 10 | python3 -m venv venv 11 | source venv/bin/activate 12 | pip install -e ../../ 13 | pip install -r requirements.txt 14 | 15 | ./manage.py migrate 16 | ./manage.py createsuperuser 17 | ./manage.py runserver 18 | ``` 19 | 20 | Go to `http://localhost:8000`, and start adding reminders. 21 | -------------------------------------------------------------------------------- /experiments/reminders/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'turbotutorial.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /experiments/reminders/reminders/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hotwire-django/turbo-django/185baaec7941f2abb09c90624c7678ee6260df03/experiments/reminders/reminders/__init__.py -------------------------------------------------------------------------------- /experiments/reminders/reminders/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class RemindersConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'reminders' 7 | -------------------------------------------------------------------------------- /experiments/reminders/reminders/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from reminders.models import Reminder 3 | 4 | 5 | class ReminderForm(forms.ModelForm): 6 | 7 | reminder_text = forms.CharField(label="Add", required=False) 8 | 9 | class Meta: 10 | model = Reminder 11 | fields = ('reminder_text',) 12 | -------------------------------------------------------------------------------- /experiments/reminders/reminders/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0 on 2022-02-19 20:36 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Reminder', 16 | fields=[ 17 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('reminder_text', models.CharField(max_length=255)), 19 | ('completed_date', models.DateTimeField(blank=True, null=True)), 20 | ('order', models.IntegerField(default=0)), 21 | ], 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /experiments/reminders/reminders/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hotwire-django/turbo-django/185baaec7941f2abb09c90624c7678ee6260df03/experiments/reminders/reminders/migrations/__init__.py -------------------------------------------------------------------------------- /experiments/reminders/reminders/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.urls import reverse 3 | 4 | 5 | class Reminder(models.Model): 6 | 7 | reminder_text = models.CharField(max_length=255) 8 | completed_date = models.DateTimeField(null=True, blank=True) 9 | order = models.IntegerField(default=0) 10 | -------------------------------------------------------------------------------- /experiments/reminders/reminders/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | Reminders Turbo Demo App 6 | 7 | 8 | 9 | 10 | {% include "turbo/head.html" %} 11 | 12 | {% block style %}{% endblock style %} 13 | 14 | 15 |
      16 | 17 |
      18 | 19 | {% block navbar %} 20 | 21 | 28 | 29 | {% endblock navbar %} 30 | 31 |
      32 | {% block body %} 33 | BODY 34 | {% endblock body %} 35 |
      36 | 37 |
      38 | 39 |
      40 |
      41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | {% block script %} 49 | {% endblock %} 50 | 51 | 52 | -------------------------------------------------------------------------------- /experiments/reminders/reminders/templates/reminders/reminder_list.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load turbo_streams %} 3 | 4 | 5 | {% block body %} 6 |

      Reminders

      7 |

      This application demonstrates a simple implementation of forms and turbo-frames written without any custom javascript.

      8 | 9 | 10 |
      11 | {% csrf_token %} 12 | 13 | 14 |
      15 |
      16 | 17 | {% include "reminders/reminder_list_items.html" %} 18 | 19 | 20 | 21 | {% endblock body %} 22 | 23 | -------------------------------------------------------------------------------- /experiments/reminders/reminders/templates/reminders/reminder_list_form.html: -------------------------------------------------------------------------------- 1 | 2 |
      3 | {% csrf_token %} 4 | {{ form.as_p }} 5 | 6 | 7 |
      8 |
      9 | -------------------------------------------------------------------------------- /experiments/reminders/reminders/templates/reminders/reminder_list_item.html: -------------------------------------------------------------------------------- 1 |
    • {{reminder.reminder_text}}
    • 2 | -------------------------------------------------------------------------------- /experiments/reminders/reminders/templates/reminders/reminder_list_items.html: -------------------------------------------------------------------------------- 1 |
        2 | {% for reminder in reminders %} 3 | {% include "reminders/reminder_list_item.html" %} 4 | {% endfor %} 5 |
      6 | -------------------------------------------------------------------------------- /experiments/reminders/reminders/views.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from django.shortcuts import render 3 | from turbo.shortcuts import render_frame, remove_frame 4 | 5 | from .models import Reminder 6 | from .forms import ReminderForm 7 | 8 | 9 | def reminder_list(request): 10 | return render(request, "reminders/reminder_list.html", {"reminders": Reminder.objects.all()}) 11 | 12 | 13 | def reminder_list_form(request): 14 | 15 | if request.method == "POST": 16 | 17 | action = request.POST.get('action') 18 | 19 | if action == 'Delete All': 20 | Reminder.objects.all().delete() 21 | frame = remove_frame(selector='#reminders li') 22 | return frame.response 23 | 24 | form = ReminderForm(request.POST) 25 | if form.is_valid(): 26 | reminder = form.save() 27 | frame = render_frame( 28 | request, "reminders/reminder_list_item.html", {'reminder': reminder} 29 | ).append(id='reminders') 30 | return frame.response 31 | 32 | else: 33 | form = ReminderForm() 34 | 35 | return render(request, "reminders/reminder_list_form.html", {"form": form}) 36 | 37 | 38 | def reminder_list_search(request): 39 | # Post request to search 40 | reminders = Reminder.objects.filter(reminder_text__icontains=request.POST.get('search', '')) 41 | frame = render_frame(request, "reminders/reminder_list_items.html", {"reminders": reminders}) 42 | return frame.replace(id='reminders').response 43 | -------------------------------------------------------------------------------- /experiments/reminders/requirements.txt: -------------------------------------------------------------------------------- 1 | channels 2 | asgiref 3 | django 4 | 5 | # Dev deps 6 | pytest 7 | pytest-django 8 | -------------------------------------------------------------------------------- /experiments/reminders/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hotwire-django/turbo-django/185baaec7941f2abb09c90624c7678ee6260df03/experiments/reminders/test/__init__.py -------------------------------------------------------------------------------- /experiments/reminders/test/context.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..'))) 5 | -------------------------------------------------------------------------------- /experiments/reminders/test/test_basic.py: -------------------------------------------------------------------------------- 1 | from . import context 2 | import turbo 3 | 4 | 5 | def test_tests(): 6 | assert True 7 | 8 | 9 | def test_import(): 10 | assert turbo.default_app_config == "turbo.apps.TurboDjangoConfig" 11 | -------------------------------------------------------------------------------- /experiments/reminders/turbotutorial/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hotwire-django/turbo-django/185baaec7941f2abb09c90624c7678ee6260df03/experiments/reminders/turbotutorial/__init__.py -------------------------------------------------------------------------------- /experiments/reminders/turbotutorial/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for turbotutorial project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | from channels.routing import ProtocolTypeRouter 14 | from turbo.consumers import TurboStreamsConsumer 15 | 16 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'turbodjango.settings') 17 | 18 | 19 | application = ProtocolTypeRouter( 20 | { 21 | "http": get_asgi_application(), 22 | "websocket": TurboStreamsConsumer.as_asgi(), # Leave off .as_asgi() if using Channels 2.x 23 | } 24 | ) 25 | -------------------------------------------------------------------------------- /experiments/reminders/turbotutorial/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for turbotutorial project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.2.5. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.2/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'django-insecure-k-zmgkm*7x_-&&s=g87(2dxw%l=v$s@py28sw88m@qm#it*y=q' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 'turbo', 41 | 'channels', 42 | 'reminders', 43 | ] 44 | 45 | MIDDLEWARE = [ 46 | 'django.middleware.security.SecurityMiddleware', 47 | 'django.contrib.sessions.middleware.SessionMiddleware', 48 | 'django.middleware.common.CommonMiddleware', 49 | 'django.middleware.csrf.CsrfViewMiddleware', 50 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 51 | 'django.contrib.messages.middleware.MessageMiddleware', 52 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 53 | ] 54 | 55 | ROOT_URLCONF = 'turbotutorial.urls' 56 | 57 | TEMPLATES = [ 58 | { 59 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 60 | 'DIRS': [], 61 | 'APP_DIRS': True, 62 | 'OPTIONS': { 63 | 'context_processors': [ 64 | 'django.template.context_processors.debug', 65 | 'django.template.context_processors.request', 66 | 'django.contrib.auth.context_processors.auth', 67 | 'django.contrib.messages.context_processors.messages', 68 | ] 69 | }, 70 | } 71 | ] 72 | 73 | # WSGI_APPLICATION = 'turbotutorial.wsgi.application' 74 | ASGI_APPLICATION = 'turbotutorial.asgi.application' 75 | 76 | 77 | # Database 78 | # https://docs.djangoproject.com/en/3.2/ref/settings/#databases 79 | 80 | DATABASES = {'default': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3'}} 81 | 82 | CHANNEL_LAYERS = { 83 | "default": { 84 | # Don't use this backend in production 85 | # See https://channels.readthedocs.io/en/latest/topics/channel_layers.html 86 | "BACKEND": "channels.layers.InMemoryChannelLayer" 87 | } 88 | } 89 | 90 | 91 | # Password validation 92 | # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators 93 | 94 | AUTH_PASSWORD_VALIDATORS = [ 95 | {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, 96 | {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, 97 | {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, 98 | {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, 99 | ] 100 | 101 | 102 | # Internationalization 103 | # https://docs.djangoproject.com/en/3.2/topics/i18n/ 104 | 105 | LANGUAGE_CODE = 'en-us' 106 | 107 | TIME_ZONE = 'UTC' 108 | 109 | USE_I18N = True 110 | 111 | USE_L10N = True 112 | 113 | USE_TZ = True 114 | 115 | 116 | # Static files (CSS, JavaScript, Images) 117 | # https://docs.djangoproject.com/en/3.2/howto/static-files/ 118 | 119 | STATIC_URL = '/static/' 120 | STATIC_ROOT = '' 121 | 122 | # Default primary key field type 123 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field 124 | 125 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 126 | -------------------------------------------------------------------------------- /experiments/reminders/turbotutorial/urls.py: -------------------------------------------------------------------------------- 1 | """turbotutorial URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.urls import path 17 | from django.views.generic import TemplateView 18 | 19 | from reminders import views 20 | 21 | urlpatterns = [ 22 | path('', views.reminder_list, name='reminder_list'), 23 | path('form/', views.reminder_list_form, name='reminder_list_form'), 24 | path('search/', views.reminder_list_search, name='reminder_list_search'), 25 | ] 26 | -------------------------------------------------------------------------------- /experiments/reminders/turbotutorial/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for turbotutorial project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'turbotutorial.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "turbo-django" 3 | version = "0.4.3" 4 | description = "Integrate Hotwire Turbo with Django allowing for a Python-driven dynamic web experience." 5 | authors = [ 6 | "Nikita Marchant ", 7 | "Davis Haupt ", 8 | "Bo Lopker ", 9 | "Julian Feinauer ", 10 | "Edgard Pineda ", 11 | "Stephen Mitchell " 12 | ] 13 | readme = "README.md" 14 | license = "MIT" 15 | packages = [ 16 | { include = "turbo" } 17 | ] 18 | classifiers = [ 19 | "Environment :: Web Environment", 20 | "Framework :: Django", 21 | "Framework :: Django :: 3.2", 22 | "Intended Audience :: Developers", 23 | "Operating System :: OS Independent", 24 | "Programming Language :: Python", 25 | "Programming Language :: Python :: 3", 26 | "Programming Language :: Python :: 3 :: Only", 27 | "Programming Language :: Python :: 3.7", 28 | "Programming Language :: Python :: 3.8", 29 | "Programming Language :: Python :: 3.9", 30 | ] 31 | 32 | [tool.poetry.dependencies] 33 | python = "^3.8" 34 | Django = ">=3.2.0" 35 | channels = ">=2.0.0" 36 | 37 | 38 | [tool.poetry.dev-dependencies] 39 | sphinx-autoapi = "^1.8.1" 40 | alabaster-hotwire = {git = "https://github.com/hotwire-django/sphinx-hotwire-theme.git", rev = "1.0"} 41 | sphinxcontrib-fulltoc = "^1.2.0" 42 | bump2version = "^1.0.1" 43 | pyproject-flake8 = "^0.0.1-alpha.2" 44 | pytest = "^7.1.2" 45 | pytest-django = "^4.5.2" 46 | black = "^22.3.0" 47 | coverage = "^6.3.2" 48 | 49 | [build-system] 50 | requires = ["poetry-core>=1.0.0"] 51 | build-backend = "poetry.core.masonry.api" 52 | 53 | [tool.flake8] 54 | max-line-length = 100 55 | max-complexity = 10 56 | exclude = 'doc,migrations' 57 | 58 | [tool.black] 59 | line-length = 100 60 | target-version = ['py38'] 61 | include = '\.pyi?$' 62 | extend-exclude = 'doc|migrations' 63 | 64 | 65 | [tool.pytest.ini_options] 66 | addopts=["--tb=short", "--strict", "-ra", "--ignore-glob=experiments"] 67 | pythonpath = ["turbo", "tests"] 68 | DJANGO_SETTINGS_MODULE = 'test_settings' 69 | testpaths = [ 70 | "tests" 71 | ] 72 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pytest==7.1.2 2 | Django==3.2 3 | channels==3.0.3 4 | black==22.3.0 5 | flake8==3.8.4 6 | pyproject-flake8==0.0.1a2 7 | pytest-django==4.5.2 8 | -------------------------------------------------------------------------------- /run_git_checks.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | pflake8 turbo 4 | black -S --target-version=py38 --line-length=100 . --exclude "doc|migrations" 5 | pytest turbo 6 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hotwire-django/turbo-django/185baaec7941f2abb09c90624c7678ee6260df03/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_app/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Stephen Mitchell 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 4 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 5 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit 6 | persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 9 | Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 12 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 13 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 14 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | -------------------------------------------------------------------------------- /tests/test_app/README.md: -------------------------------------------------------------------------------- 1 | # Django Hotwire Demo - Reminders 2 | 3 | This repository contains a demonstration of [Hotwire](https://hotwired.dev), and 4 | how a site can be built using turbo-frames. 5 | 6 | To run this demo, after cloning the repository: 7 | 8 | ```bash 9 | cd experiments/reminders 10 | python3 -m venv venv 11 | source venv/bin/activate 12 | pip install -e ../../ 13 | pip install -r requirements.txt 14 | 15 | ./manage.py migrate 16 | ./manage.py createsuperuser 17 | ./manage.py runserver 18 | ``` 19 | 20 | Go to `http://localhost:8000`, and start adding reminders. 21 | -------------------------------------------------------------------------------- /tests/test_app/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hotwire-django/turbo-django/185baaec7941f2abb09c90624c7678ee6260df03/tests/test_app/app/__init__.py -------------------------------------------------------------------------------- /tests/test_app/app/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for turbotutorial project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | from channels.routing import ProtocolTypeRouter 14 | from turbo.consumers import TurboStreamsConsumer 15 | 16 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'turbodjango.settings') 17 | 18 | 19 | application = ProtocolTypeRouter( 20 | { 21 | "http": get_asgi_application(), 22 | "websocket": TurboStreamsConsumer.as_asgi(), # Leave off .as_asgi() if using Channels 2.x 23 | } 24 | ) 25 | -------------------------------------------------------------------------------- /tests/test_app/app/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for turbotutorial project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.2.5. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.2/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = 'django-insecure-k-zmgkm*7x_-&&s=g87(2dxw%l=v$s@py28sw88m@qm#it*y=q' 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = [] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | 'django.contrib.admin', 35 | 'django.contrib.auth', 36 | 'django.contrib.contenttypes', 37 | 'django.contrib.sessions', 38 | 'django.contrib.messages', 39 | 'django.contrib.staticfiles', 40 | 'turbo', 41 | 'channels', 42 | 'quickstart', 43 | ] 44 | 45 | MIDDLEWARE = [ 46 | 'django.middleware.security.SecurityMiddleware', 47 | 'django.contrib.sessions.middleware.SessionMiddleware', 48 | 'django.middleware.common.CommonMiddleware', 49 | 'django.middleware.csrf.CsrfViewMiddleware', 50 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 51 | 'django.contrib.messages.middleware.MessageMiddleware', 52 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 53 | ] 54 | 55 | ROOT_URLCONF = 'turbotutorial.urls' 56 | 57 | TEMPLATES = [ 58 | { 59 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 60 | 'DIRS': [], 61 | 'APP_DIRS': True, 62 | 'OPTIONS': { 63 | 'context_processors': [ 64 | 'django.template.context_processors.debug', 65 | 'django.template.context_processors.request', 66 | 'django.contrib.auth.context_processors.auth', 67 | 'django.contrib.messages.context_processors.messages', 68 | ] 69 | }, 70 | } 71 | ] 72 | 73 | # WSGI_APPLICATION = 'turbotutorial.wsgi.application' 74 | ASGI_APPLICATION = 'turbotutorial.asgi.application' 75 | 76 | 77 | # Database 78 | # https://docs.djangoproject.com/en/3.2/ref/settings/#databases 79 | 80 | DATABASES = {'default': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3'}} 81 | 82 | CHANNEL_LAYERS = { 83 | "default": { 84 | "BACKEND": "channels_redis.core.RedisChannelLayer", 85 | "CONFIG": { 86 | "hosts": [("127.0.0.1", 6379)], 87 | }, 88 | }, 89 | } 90 | 91 | # Password validation 92 | # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators 93 | 94 | AUTH_PASSWORD_VALIDATORS = [ 95 | {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, 96 | {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, 97 | {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, 98 | {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, 99 | ] 100 | 101 | 102 | # Internationalization 103 | # https://docs.djangoproject.com/en/3.2/topics/i18n/ 104 | 105 | LANGUAGE_CODE = 'en-us' 106 | 107 | TIME_ZONE = 'UTC' 108 | 109 | USE_I18N = True 110 | 111 | USE_L10N = True 112 | 113 | USE_TZ = True 114 | 115 | 116 | # Static files (CSS, JavaScript, Images) 117 | # https://docs.djangoproject.com/en/3.2/howto/static-files/ 118 | 119 | STATIC_URL = '/static/' 120 | STATIC_ROOT = '' 121 | 122 | # Default primary key field type 123 | # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field 124 | 125 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 126 | -------------------------------------------------------------------------------- /tests/test_app/app/urls.py: -------------------------------------------------------------------------------- 1 | """turbotutorial URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.urls import path 17 | from django.views.generic import TemplateView 18 | 19 | urlpatterns = [path('', TemplateView.as_view(template_name='broadcast_example.html'))] 20 | -------------------------------------------------------------------------------- /tests/test_app/app/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for turbotutorial project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'turbotutorial.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /tests/test_app/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'turbotutorial.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /tests/test_app/quickstart/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hotwire-django/turbo-django/185baaec7941f2abb09c90624c7678ee6260df03/tests/test_app/quickstart/__init__.py -------------------------------------------------------------------------------- /tests/test_app/quickstart/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class QuickStartConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'quickstart' 7 | -------------------------------------------------------------------------------- /tests/test_app/quickstart/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0 on 2022-02-19 20:36 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Reminder', 16 | fields=[ 17 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('reminder_text', models.CharField(max_length=255)), 19 | ('completed_date', models.DateTimeField(blank=True, null=True)), 20 | ('order', models.IntegerField(default=0)), 21 | ], 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /tests/test_app/quickstart/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hotwire-django/turbo-django/185baaec7941f2abb09c90624c7678ee6260df03/tests/test_app/quickstart/migrations/__init__.py -------------------------------------------------------------------------------- /tests/test_app/quickstart/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from django.db import models 3 | 4 | 5 | class Review(models.Model): 6 | 7 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 8 | title = models.CharField(max_length=255) 9 | rating = models.IntegerField(default=0) 10 | -------------------------------------------------------------------------------- /tests/test_app/quickstart/streams.py: -------------------------------------------------------------------------------- 1 | import turbo 2 | from quickstart.models import Review 3 | 4 | 5 | class BroadcastStream(turbo.Stream): 6 | pass 7 | 8 | 9 | class RatingStream(turbo.ModelStream): 10 | class Meta: 11 | model = Review 12 | -------------------------------------------------------------------------------- /tests/test_app/quickstart/templates/broadcast_example.html: -------------------------------------------------------------------------------- 1 | {% load turbo_streams %} 2 | 3 | 4 | 5 | {% include "turbo/head.html" %} 6 | 7 | 8 | {% turbo_subscribe 'quickstart:BroadcastStream' %} 9 |

      Broadcast Quickstart

      10 |

      Placeholder for broadcast

      11 | 12 | 13 | -------------------------------------------------------------------------------- /tests/test_app/requirements.txt: -------------------------------------------------------------------------------- 1 | channels 2 | asgiref 3 | django 4 | 5 | # Dev deps 6 | pytest 7 | pytest-django 8 | -------------------------------------------------------------------------------- /tests/test_app/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hotwire-django/turbo-django/185baaec7941f2abb09c90624c7678ee6260df03/tests/test_app/test/__init__.py -------------------------------------------------------------------------------- /tests/test_app/test/context.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..'))) 5 | -------------------------------------------------------------------------------- /tests/test_app/test/test_basic.py: -------------------------------------------------------------------------------- 1 | from . import context 2 | import turbo 3 | 4 | 5 | def test_tests(): 6 | assert True 7 | 8 | 9 | def test_import(): 10 | assert turbo.default_app_config == "turbo.apps.TurboDjangoConfig" 11 | -------------------------------------------------------------------------------- /tests/test_basic.py: -------------------------------------------------------------------------------- 1 | import turbo 2 | 3 | 4 | def test_tests(): 5 | assert True 6 | 7 | 8 | def test_import(): 9 | assert turbo.default_app_config == "turbo.apps.TurboDjangoConfig" 10 | 11 | 12 | class TestChannel(turbo.Stream): 13 | class Meta: 14 | app_name = 'test' 15 | 16 | pass 17 | 18 | 19 | def test_channel_name(): 20 | 21 | assert TestChannel.stream_name == "test:TestChannel" 22 | pass 23 | -------------------------------------------------------------------------------- /tests/test_registry.py: -------------------------------------------------------------------------------- 1 | import turbo 2 | 3 | from turbo.classes import Stream 4 | from turbo.registry import stream_for_stream_name, stream_registry 5 | 6 | 7 | class TestStream(turbo.Stream): 8 | pass 9 | 10 | 11 | def test_registry_invalid_names(): 12 | 13 | # test invalid stream names 14 | assert stream_for_stream_name('') is None 15 | assert stream_for_stream_name('a') is None 16 | 17 | 18 | def test_registry_add_stream(): 19 | 20 | # test stream before it is added to the registry 21 | assert stream_for_stream_name('test_app:TestStream') is None 22 | 23 | # test stream after it is added to the registry 24 | stream_registry.add_stream('test_app', 'TestStream', TestStream) 25 | assert stream_for_stream_name('test_app:TestStream') is TestStream 26 | 27 | assert stream_registry.get_stream_names() == [":TestStream"] 28 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | BASE_DIR = Path(__file__).resolve().parent.parent 4 | 5 | TEMPLATES = [ 6 | { 7 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 8 | 'DIRS': [ 9 | BASE_DIR / 'turbo' / 'templates', 10 | ], 11 | 'APP_DIRS': True, 12 | }, 13 | ] 14 | 15 | CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}} 16 | 17 | USE_TZ = False 18 | 19 | SECRET_KEY = "turbo-test" 20 | -------------------------------------------------------------------------------- /tests/test_stream.py: -------------------------------------------------------------------------------- 1 | import turbo 2 | from unittest import mock 3 | 4 | from turbo.classes import Stream 5 | 6 | 7 | def test_tests(): 8 | assert True 9 | 10 | 11 | def test_import(): 12 | assert turbo.default_app_config == "turbo.apps.TurboDjangoConfig" 13 | 14 | 15 | class TestStream(turbo.Stream): 16 | pass 17 | 18 | 19 | @mock.patch("turbo.classes.async_to_sync") 20 | def test_stream(async_to_sync): 21 | stream = TestStream() 22 | assert stream.stream_name == ':TestStream' 23 | 24 | stream.append(text="Hi", id="id_of_object") 25 | async_to_sync.assert_called_once() 26 | 27 | 28 | @mock.patch.object(Stream, "stream_raw") 29 | def test_stream_actions(stream_raw): 30 | stream = TestStream() 31 | assert stream.stream_name == ':TestStream' 32 | 33 | actions = ('append', 'prepend', 'replace', 'update', 'before', 'after') 34 | for action_name in actions: 35 | action_method = getattr(stream, action_name) 36 | action_method(text="Hi", id="id_of_object") 37 | 38 | assert stream_raw.call_count == 1 39 | args = stream_raw.mock_calls[0].args 40 | assert args == ( 41 | f'', 42 | ) 43 | stream_raw.reset_mock() 44 | 45 | 46 | @mock.patch.object(Stream, "stream_raw") 47 | def test_stream_remove(stream_raw): 48 | stream = TestStream() 49 | assert stream.stream_name == ':TestStream' 50 | 51 | actions = ('append', 'prepend', 'replace', 'update', 'before', 'after') 52 | 53 | stream.remove(id="id_of_object") 54 | 55 | assert stream_raw.call_count == 1 56 | args = stream_raw.mock_calls[0].args 57 | assert args == ( 58 | '', 59 | ) 60 | -------------------------------------------------------------------------------- /turbo/__init__.py: -------------------------------------------------------------------------------- 1 | # Bring classes up to turbo namespace. 2 | from .classes import ( # noqa: F401 3 | Stream, 4 | ModelStream, 5 | APPEND, 6 | PREPEND, 7 | REPLACE, 8 | UPDATE, 9 | REMOVE, 10 | BEFORE, 11 | AFTER, 12 | TurboRender, 13 | ) 14 | from .module_loading import autodiscover_streams 15 | from .registry import stream_registry 16 | from .shortcuts import render_frame, render_frame_string, remove_frame # noqa: F401 17 | 18 | default_app_config = "turbo.apps.TurboDjangoConfig" 19 | 20 | 21 | def autodiscover(): 22 | autodiscover_streams(stream_registry) 23 | -------------------------------------------------------------------------------- /turbo/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TurboDjangoConfig(AppConfig): 5 | """The default AppConfig for admin which does autodiscovery.""" 6 | 7 | name = "turbo" 8 | 9 | def ready(self): 10 | super().ready() 11 | self.module.autodiscover() 12 | -------------------------------------------------------------------------------- /turbo/classes.py: -------------------------------------------------------------------------------- 1 | from asgiref.sync import async_to_sync 2 | from channels.layers import get_channel_layer 3 | from functools import cached_property 4 | 5 | from django.http import HttpResponse 6 | from django.template.loader import render_to_string 7 | 8 | from .metaclass import DeclarativeFieldsMetaclass 9 | 10 | import json 11 | from django.core.signing import Signer 12 | from django.core.serializers.json import DjangoJSONEncoder 13 | import hashlib 14 | 15 | # Turbo Streams CRUD operations 16 | APPEND = "append" 17 | PREPEND = "prepend" 18 | REPLACE = "replace" 19 | UPDATE = "update" 20 | REMOVE = "remove" 21 | BEFORE = "before" 22 | AFTER = "after" 23 | 24 | 25 | SELECTOR_CSS = "css" 26 | SELECTOR_ID = "id" 27 | SELECTOR_TYPES = (SELECTOR_ID, SELECTOR_CSS) 28 | 29 | signer = Signer() 30 | 31 | 32 | class DjangoJSONSerializer: 33 | """ 34 | Simple wrapper around json to be used in signing.dumps and 35 | signing.loads. 36 | """ 37 | 38 | def dumps(self, obj): 39 | return json.dumps(obj, cls=DjangoJSONEncoder, separators=(',', ':')).encode('latin-1') 40 | 41 | def loads(self, data): 42 | return json.loads(data.decode('latin-1'), cls=DjangoJSONEncoder) 43 | 44 | 45 | class classproperty: 46 | def __init__(self, method=None): 47 | self.fget = method 48 | 49 | def __get__(self, instance, cls=None): 50 | return self.fget(cls) 51 | 52 | def getter(self, method): 53 | self.fget = method 54 | return self 55 | 56 | 57 | class Stream(metaclass=DeclarativeFieldsMetaclass): 58 | """ 59 | A reference to a specific broadcast. 60 | """ 61 | 62 | class Meta: 63 | app_name = "" 64 | 65 | @classproperty 66 | def stream_name(self): 67 | """A unique string that will identify this Stream""" 68 | return f"{self._meta.app_name}:{self.__name__}" 69 | 70 | @property 71 | def signed_stream_name(self): 72 | """A unique string that will identify this Stream""" 73 | return signer.sign_object( 74 | (self.stream_name, self.get_init_args(), self.get_init_kwargs()), 75 | serializer=DjangoJSONSerializer, 76 | ) 77 | 78 | @property 79 | def broadcastable_stream_name(self): 80 | """ 81 | A unique string that can be used by channels. 82 | A-Z, hyphens and dashes only. Less than 99 characters 83 | """ 84 | return hashlib.md5(self.signed_stream_name.encode('utf-8')).hexdigest() 85 | 86 | def get_init_args(self): 87 | return [] 88 | 89 | def get_init_kwargs(self): 90 | return {} 91 | 92 | def get_init_args_json(self) -> str: 93 | return json.dumps(self.get_init_args(), cls=DjangoJSONEncoder) 94 | 95 | def get_init_kwargs_json(self) -> str: 96 | return json.dumps(self.get_init_kwargs(), cls=DjangoJSONEncoder) 97 | 98 | def _get_frame(self, template=None, context=None, text=None): 99 | if text: 100 | return TurboRender(text) 101 | else: 102 | return TurboRender.init_from_template(template, context) 103 | 104 | def append(self, template=None, context=None, text=None, selector=None, id=None): 105 | """Shortcut to stream an append frame""" 106 | 107 | frame = self._get_frame(template, context, text) 108 | frame.append(selector=selector, id=id) 109 | self.stream(frame) 110 | 111 | def prepend(self, template=None, context=None, text=None, selector=None, id=None): 112 | """Shortcut to stream an append frame""" 113 | frame = self._get_frame(template, context, text) 114 | frame.prepend(selector=selector, id=id) 115 | self.stream(frame) 116 | 117 | def replace(self, template=None, context=None, text=None, selector=None, id=None): 118 | """Shortcut to stream an append frame""" 119 | frame = self._get_frame(template, context, text) 120 | frame.replace(selector=selector, id=id) 121 | self.stream(frame) 122 | 123 | def update(self, template=None, context=None, text=None, selector=None, id=None): 124 | """Shortcut to stream an append frame""" 125 | frame = self._get_frame(template, context, text) 126 | frame.update(selector=selector, id=id) 127 | self.stream(frame) 128 | 129 | def before(self, template=None, context=None, text=None, selector=None, id=None): 130 | """Shortcut to stream an append frame""" 131 | frame = self._get_frame(template, context, text) 132 | frame.before(selector=selector, id=id) 133 | self.stream(frame) 134 | 135 | def after(self, template=None, context=None, text=None, selector=None, id=None): 136 | """Shortcut to stream an append frame""" 137 | frame = self._get_frame(template, context, text) 138 | frame.after(selector=selector, id=id) 139 | self.stream(frame) 140 | 141 | def remove(self, selector=None, id=None): 142 | """ 143 | Send a broadcast to remove an element from a turbo frame. 144 | """ 145 | # Remove does not require a template so allow it to pass through without a render(). 146 | remove_frame = TurboRender().remove(selector, id) 147 | self.stream(remove_frame) 148 | 149 | def stream_raw(self, raw_text: str): 150 | channel_layer = get_channel_layer() 151 | 152 | async_to_sync(channel_layer.group_send)( 153 | self.broadcastable_stream_name, 154 | { 155 | "type": "notify", 156 | "signed_channel_name": self.signed_stream_name, 157 | "rendered_template": raw_text, 158 | }, 159 | ) 160 | 161 | def stream(self, frame: "TurboRender"): 162 | if not frame.rendered_template: 163 | raise ValueError("No action (append, update, remove...) assigned to Turbo Frame.") 164 | self.stream_raw(frame.rendered_template) 165 | 166 | def user_passes_test(self, user) -> bool: 167 | return True 168 | 169 | 170 | class ModelStream(Stream): 171 | def __init__(self, pk, instance=None): 172 | super().__init__() 173 | self.pk = pk 174 | if instance: 175 | self.instance = instance 176 | 177 | @classmethod 178 | def from_pk(cls, pk): 179 | return cls(pk, None) 180 | 181 | @classmethod 182 | def from_instance(cls, instance): 183 | return cls(instance.pk, instance) 184 | 185 | @cached_property 186 | def instance(self): 187 | return self._meta.model.objects.get(pk=self.pk) 188 | 189 | def get_init_args(self): 190 | """A JSON serializable list that can rebuild the Stream instance""" 191 | return [self.pk] 192 | 193 | def user_passes_test(self, user): 194 | return True 195 | 196 | # Optionally defined in inherited classes 197 | # 198 | # def on_save(self, instance, created, *args, **kwargs): 199 | # pass 200 | 201 | # def on_delete(self, instance, *args, **kwargs): 202 | # pass 203 | 204 | 205 | class TurboRender: 206 | """ 207 | A rendered template, ready to broadcast using turbo. 208 | """ 209 | 210 | def __init__(self, inner_html: str = ""): 211 | self.inner_html = inner_html 212 | self._rendered_template = None 213 | 214 | @classmethod 215 | def init_from_template(cls, template_name: str, context=None, request=None) -> "TurboRender": 216 | """ 217 | Returns a TurboRender object from a django template. This rendered template 218 | can then be broadcast to subscribers with the TurboRender actions 219 | (eg: append, update, etc...) 220 | 221 | Takes a template name and context identical to Django's render() method. 222 | """ 223 | if context is None: 224 | context = {} 225 | 226 | return cls(render_to_string(template_name, context, request)) 227 | 228 | def append(self, selector=None, id=None): 229 | """Add a target action to the given selector.""" 230 | return self._add_target(selector, id, APPEND) 231 | 232 | def prepend(self, selector=None, id=None): 233 | """Add a target action to the given selector.""" 234 | return self._add_target(selector, id, PREPEND) 235 | 236 | def replace(self, selector=None, id=None): 237 | """Add a target action to the given selector.""" 238 | return self._add_target(selector, id, REPLACE) 239 | 240 | def update(self, selector=None, id=None): 241 | """Add a target action to the given selector.""" 242 | return self._add_target(selector, id, UPDATE) 243 | 244 | def remove(self, selector=None, id=None): 245 | """Add a target action to the given selector.""" 246 | return self._add_target(selector, id, REMOVE) 247 | 248 | def before(self, selector=None, id=None): 249 | """Add a target action to the given selector.""" 250 | return self._add_target(selector, id, BEFORE) 251 | 252 | def after(self, selector=None, id=None): 253 | """Add a target action to the given selector.""" 254 | return self._add_target(selector, id, AFTER) 255 | 256 | def _add_target(self, selector, id, action): 257 | 258 | if (selector is None) == (id is None): # noqa: E711 259 | raise ValueError("Either selector or id can be used as a parameter.") 260 | 261 | selector_type = SELECTOR_CSS 262 | if selector is None: 263 | selector_type = SELECTOR_ID 264 | selector = id 265 | 266 | return self.render(selector_type=selector_type, selector=selector, action=action) 267 | 268 | @property 269 | def response(self): 270 | return TurboResponse(self) 271 | 272 | @property 273 | def rendered_template(self): 274 | if self._rendered_template is None: 275 | raise ValueError( 276 | "Template must be rendered with an template action (append, update, remove, etc...)" 277 | ) 278 | return self._rendered_template 279 | 280 | def render(self, selector_type, selector, action) -> str: 281 | 282 | template_context = { 283 | "action": action, 284 | "use_css_selector": selector_type == SELECTOR_CSS, 285 | "selector": selector, 286 | } 287 | 288 | # Remove actions don't have contents, so only add context for model 289 | # template if it's not a remove action. 290 | if action != REMOVE: 291 | template_context["rendered_template"] = self.inner_html 292 | 293 | self._rendered_template = render_to_string("turbo/stream.html", template_context) 294 | return self 295 | 296 | def stream_to(self, stream_instance): 297 | stream_instance.stream(self.rendered_template) 298 | 299 | 300 | class TurboResponse(HttpResponse): 301 | """ 302 | An Trubo response class with TurboRendered frames as content""" 303 | 304 | def __init__(self, *frames, **kwargs): 305 | 306 | super().__init__(**kwargs) 307 | 308 | self.headers['Content-Type'] = "text/vnd.turbo-stream.html" 309 | self.status_code = 200 310 | 311 | self.frames = frames 312 | self.update_content() 313 | 314 | def add_frame(self, frame): 315 | self.frames.append(frame) 316 | self.update_content() 317 | 318 | def update_content(self): 319 | self.content = "".join([frame.rendered_template for frame in self.frames]) 320 | -------------------------------------------------------------------------------- /turbo/components.py: -------------------------------------------------------------------------------- 1 | from turbo.classes import Stream 2 | from django.template.loader import render_to_string 3 | from django.contrib.auth import get_user_model 4 | from django.contrib.auth.models import AnonymousUser 5 | 6 | 7 | class BaseComponent(Stream): 8 | 9 | template_name = None 10 | 11 | def get_context(self): 12 | """ 13 | Return the default context to render a component. 14 | """ 15 | return {} 16 | 17 | def compute_context(self, context, **context_kwargs): 18 | """ 19 | Calculate the context to render. 20 | """ 21 | new_context = self.get_context() 22 | if context: 23 | new_context.update(context) 24 | new_context.update(context_kwargs) 25 | 26 | return new_context 27 | 28 | def render(self, context={}, **context_kwargs): 29 | context = self.compute_context(context, **context_kwargs) 30 | self.update(self.template_name, context, id=self.stream_name) 31 | 32 | def initial_render(self, context): 33 | """ 34 | Returns the html origially rendered on the page. 35 | """ 36 | context = self.compute_context(context) 37 | return render_to_string(self.template_name, context) 38 | 39 | 40 | class BroadcastComponent(BaseComponent): 41 | """ 42 | A component that broadcasts the same content to all subscribed users. 43 | """ 44 | 45 | pass 46 | 47 | 48 | class UserBroadcastComponent(BaseComponent): 49 | """ 50 | A component that broadcasts a template to a specific user. 51 | """ 52 | 53 | template_name = None 54 | 55 | def __init__(self, user): 56 | 57 | if user is None: 58 | user = AnonymousUser 59 | 60 | try: 61 | if not isinstance(user, get_user_model()): 62 | user = get_user_model().objects.get(pk=user) 63 | except TypeError: 64 | pass 65 | 66 | self.user = user 67 | super().__init__() 68 | 69 | def get_init_args(self): 70 | return [self.user.pk] 71 | 72 | def user_passes_test(self, request_user): 73 | if request_user and request_user.is_authenticated: 74 | return True 75 | -------------------------------------------------------------------------------- /turbo/consumers.py: -------------------------------------------------------------------------------- 1 | from asgiref.sync import async_to_sync 2 | from channels.generic.websocket import JsonWebsocketConsumer 3 | from django.core.signing import Signer, BadSignature 4 | 5 | import logging 6 | 7 | from .registry import stream_for_stream_name 8 | 9 | logger = logging.getLogger("turbo.streams") 10 | 11 | signer = Signer() 12 | 13 | 14 | class TurboStreamException(Exception): 15 | pass 16 | 17 | 18 | class TurboStreamsConsumer(JsonWebsocketConsumer): 19 | def connect(self): 20 | self.accept() 21 | 22 | def notify(self, event): 23 | self.send_json( 24 | { 25 | "signed_channel_name": event["signed_channel_name"], 26 | "data": event.get("rendered_template"), 27 | } 28 | ) 29 | 30 | def receive_json(self, content, **kwargs): 31 | try: 32 | stream_name, args, kwargs = signer.unsign_object(content["signed_channel_name"]) 33 | except (BadSignature, KeyError): 34 | raise TurboStreamException( 35 | "Signature is invalid or not present. This could be due to a misbehaving client." 36 | ) 37 | 38 | message_type = content["type"] 39 | Stream = stream_for_stream_name(stream_name) 40 | 41 | if Stream: 42 | stream = Stream(*args, **kwargs) 43 | else: 44 | logger.warning("Stream '%s' could not be located.", stream_name) 45 | return 46 | 47 | self.subscribe_to_stream(message_type, stream, self.scope.get("user")) 48 | 49 | def subscribe_to_stream(self, message_type, stream, user): 50 | if not stream.user_passes_test(user): 51 | logger.warning( 52 | "User `%s` does not have permission to access stream '%s'.", 53 | user, 54 | stream.stream_name, 55 | ) 56 | return False 57 | 58 | stream_name = stream.broadcastable_stream_name 59 | if message_type == "subscribe": 60 | async_to_sync(self.channel_layer.group_add)(stream_name, self.channel_name) 61 | elif message_type == "unsubscribe": 62 | async_to_sync(self.channel_layer.group_discard)(stream_name, self.channel_name) 63 | -------------------------------------------------------------------------------- /turbo/metaclass.py: -------------------------------------------------------------------------------- 1 | class AttrDict(dict): 2 | """A dictionary with attribute-style access. It maps attribute access to 3 | the real dictionary.""" 4 | 5 | def __init__(self, init={}): 6 | dict.__init__(self, init) 7 | 8 | def __getstate__(self): 9 | return self.__dict__.items() 10 | 11 | def __setstate__(self, items): 12 | for key, val in items: 13 | self.__dict__[key] = val 14 | 15 | def __repr__(self): 16 | return "%s(%s)" % (self.__class__.__name__, dict.__repr__(self)) 17 | 18 | def __setitem__(self, key, value): 19 | return super(AttrDict, self).__setitem__(key, value) 20 | 21 | def __getitem__(self, name): 22 | return super(AttrDict, self).__getitem__(name) 23 | 24 | def __delitem__(self, name): 25 | return super(AttrDict, self).__delitem__(name) 26 | 27 | def __dir__(self): 28 | """Replace dict autocomplete choices with dict keys""" 29 | return self.keys() 30 | 31 | __getattr__ = __getitem__ 32 | __setattr__ = __setitem__ 33 | 34 | 35 | def assign_meta(new_class, bases, meta): 36 | m = {} 37 | for base in bases: 38 | m.update({k: v for k, v in getattr(base, "_meta", {}).items()}) 39 | 40 | m.update({k: v for k, v in getattr(meta, "__dict__", {}).items() if not k.startswith("__")}) 41 | _meta = AttrDict(m) 42 | 43 | return _meta 44 | 45 | 46 | class DeclarativeFieldsMetaclass(type): 47 | """ 48 | Metaclass that updates a _meta dict declared on base classes. 49 | """ 50 | 51 | def __new__(mcs, name, bases, attrs): 52 | 53 | # Pop the Meta class if exists 54 | meta = attrs.pop("Meta", None) 55 | 56 | # Value of abstract by default should be set to false. 57 | # It is never inherited. 58 | abstract = getattr(meta, "abstract", False) 59 | 60 | new_class = super().__new__(mcs, name, bases, attrs) 61 | 62 | _meta = assign_meta(new_class, bases, meta) 63 | 64 | _meta.abstract = abstract 65 | 66 | new_class._meta = _meta 67 | 68 | return new_class 69 | -------------------------------------------------------------------------------- /turbo/module_loading.py: -------------------------------------------------------------------------------- 1 | from django.utils.module_loading import autodiscover_modules 2 | from django.db.models.signals import post_init, post_save, post_delete 3 | 4 | import sys 5 | from inspect import getmembers, isclass 6 | 7 | from .classes import Stream, ModelStream # noqa: F401 8 | from turbo.signals import ( 9 | post_save_broadcast_model, 10 | post_delete_broadcast_model, 11 | post_init_broadcast_model, 12 | ) 13 | 14 | 15 | def autodiscover_streams(register_to): 16 | """ 17 | Add all Streams to registry 18 | Look for ModelStream classes in streams.py 19 | """ 20 | autodiscover_modules("streams") 21 | broadcast_modules = [v for k, v in sys.modules.items() if k.endswith(".streams")] 22 | 23 | for broadcast_module in broadcast_modules: 24 | app_name = broadcast_module.__package__.rsplit('.', 1)[-1] 25 | 26 | streams = [x for x in getmembers(broadcast_module, isclass) if issubclass(x[1], Stream)] 27 | # wrap model streams with signals and assign _meta attribute 28 | for stream_model_name, stream_model in streams: 29 | stream_model._meta.app_name = app_name 30 | if issubclass(stream_model, ModelStream): 31 | try: 32 | model = stream_model._meta.model 33 | except (KeyError, AttributeError): 34 | raise AttributeError(f"{stream_model.__name__} missing Meta.model attribute") 35 | 36 | model._meta.stream_model = stream_model 37 | post_init.connect(post_init_broadcast_model, sender=model) 38 | post_save.connect(post_save_broadcast_model, sender=model) 39 | post_delete.connect(post_delete_broadcast_model, sender=model) 40 | 41 | register_to.add_stream(app_name, stream_model_name, stream_model) 42 | -------------------------------------------------------------------------------- /turbo/registry.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | import re 3 | import logging 4 | 5 | from .classes import Stream 6 | 7 | 8 | logger = logging.getLogger("turbo.streams") 9 | 10 | stream_regex = re.compile(r"(?P\w+)\:(?P\w+)") 11 | 12 | 13 | class StreamRegistry(dict): 14 | 15 | streams_by_app = defaultdict(dict) 16 | 17 | def add_stream(self, app_name, stream_name, stream): 18 | self.streams_by_app[app_name][stream_name] = stream 19 | 20 | def get_stream(self, app_name: str, stream_name: str) -> Stream: 21 | return self.streams_by_app[app_name].get(stream_name) 22 | 23 | def get_stream_names(self): 24 | stream_names = [] 25 | for app, streams in self.streams_by_app.items(): 26 | for stream in streams.values(): 27 | stream_names.append(stream.stream_name) 28 | return stream_names 29 | 30 | 31 | def stream_for_stream_name(stream_name: str): 32 | """ 33 | Parses a stream name and returns either the stream or 34 | all streams that are associated with the instance. 35 | 36 | 37 | >>> stream_for_stream_name("app:RegularStream") 38 | >>> stream_for_stream_name("app:ModelChanel") 39 | (Stream, is_model_stream, pk) 40 | """ 41 | stream_parts = stream_regex.match(stream_name) 42 | if not stream_parts: 43 | logger.warning("Stream '%s' could not be parsed.", stream_name) 44 | return None 45 | 46 | StreamCls = stream_registry.get_stream(stream_parts["app_name"], stream_parts["stream_name"]) 47 | 48 | return StreamCls 49 | 50 | 51 | stream_registry = StreamRegistry() 52 | -------------------------------------------------------------------------------- /turbo/shortcuts.py: -------------------------------------------------------------------------------- 1 | from .classes import TurboRender 2 | 3 | 4 | def render_frame(request, template_name: str, context=None) -> "TurboRender": 5 | """ 6 | Returns a TurboRender object from a django template. This rendered template 7 | can then be broadcast to subscribers with the TurboRender actions 8 | (eg: append, update, etc...) 9 | 10 | Takes a template name and context identical to Django's render() method. 11 | """ 12 | return TurboRender.init_from_template(template_name, context=context, request=request) 13 | 14 | 15 | def render_frame_string(text: str) -> "TurboRender": 16 | """ 17 | Returns a TurboRender object from a string. 18 | """ 19 | 20 | return TurboRender(text) 21 | 22 | 23 | def remove_frame(selector=None, id=None) -> "TurboRender": 24 | """ 25 | Returns a removal frame. These don't use a template. 26 | """ 27 | 28 | return TurboRender().remove(selector, id) 29 | -------------------------------------------------------------------------------- /turbo/signals.py: -------------------------------------------------------------------------------- 1 | def post_init_broadcast_model(sender, instance, **kwargs): 2 | if hasattr(sender._meta, "stream_model"): 3 | instance.stream = sender._meta.stream_model.from_instance(instance) 4 | 5 | 6 | def post_save_broadcast_model(sender, instance, **kwargs): 7 | if hasattr(instance.stream, "on_save"): 8 | instance.stream.on_save(instance, **kwargs) 9 | 10 | 11 | def post_delete_broadcast_model(sender, instance, **kwargs): 12 | if hasattr(instance.stream, "on_delete"): 13 | instance.stream.on_delete(instance, **kwargs) 14 | -------------------------------------------------------------------------------- /turbo/static/turbo/js/reconnecting-websocket.min.js: -------------------------------------------------------------------------------- 1 | //https://unpkg.com/reconnecting-websocket@4.4.0/dist/reconnecting-websocket-iife.min.js 2 | var ReconnectingWebSocket=function(){"use strict";var e=function(t,n){return(e=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var n in t)t.hasOwnProperty(n)&&(e[n]=t[n])})(t,n)};function t(t,n){function o(){this.constructor=t}e(t,n),t.prototype=null===n?Object.create(n):(o.prototype=n.prototype,new o)}function n(e,t){var n="function"==typeof Symbol&&e[Symbol.iterator];if(!n)return e;var o,r,i=n.call(e),s=[];try{for(;(void 0===t||t-- >0)&&!(o=i.next()).done;)s.push(o.value)}catch(e){r={error:e}}finally{try{o&&!o.done&&(n=i.return)&&n.call(i)}finally{if(r)throw r.error}}return s}var o=function(){return function(e,t){this.target=t,this.type=e}}(),r=function(e){function n(t,n){var o=e.call(this,"error",n)||this;return o.message=t.message,o.error=t,o}return t(n,e),n}(o),i=function(e){function n(t,n,o){void 0===t&&(t=1e3),void 0===n&&(n="");var r=e.call(this,"close",o)||this;return r.wasClean=!0,r.code=t,r.reason=n,r}return t(n,e),n}(o),s=function(){if("undefined"!=typeof WebSocket)return WebSocket},c={maxReconnectionDelay:1e4,minReconnectionDelay:1e3+4e3*Math.random(),minUptime:5e3,reconnectionDelayGrowFactor:1.3,connectionTimeout:4e3,maxRetries:1/0,maxEnqueuedMessages:1/0,startClosed:!1,debug:!1};return function(){function e(e,t,n){var o=this;void 0===n&&(n={}),this._listeners={error:[],message:[],open:[],close:[]},this._retryCount=-1,this._shouldReconnect=!0,this._connectLock=!1,this._binaryType="blob",this._closeCalled=!1,this._messageQueue=[],this.onclose=null,this.onerror=null,this.onmessage=null,this.onopen=null,this._handleOpen=function(e){o._debug("open event");var t=o._options.minUptime,n=void 0===t?c.minUptime:t;clearTimeout(o._connectTimeout),o._uptimeTimeout=setTimeout(function(){return o._acceptOpen()},n),o._ws.binaryType=o._binaryType,o._messageQueue.forEach(function(e){return o._ws.send(e)}),o._messageQueue=[],o.onopen&&o.onopen(e),o._listeners.open.forEach(function(t){return o._callEventListener(e,t)})},this._handleMessage=function(e){o._debug("message event"),o.onmessage&&o.onmessage(e),o._listeners.message.forEach(function(t){return o._callEventListener(e,t)})},this._handleError=function(e){o._debug("error event",e.message),o._disconnect(void 0,"TIMEOUT"===e.message?"timeout":void 0),o.onerror&&o.onerror(e),o._debug("exec error listeners"),o._listeners.error.forEach(function(t){return o._callEventListener(e,t)}),o._connect()},this._handleClose=function(e){o._debug("close event"),o._clearTimeouts(),o._shouldReconnect&&o._connect(),o.onclose&&o.onclose(e),o._listeners.close.forEach(function(t){return o._callEventListener(e,t)})},this._url=e,this._protocols=t,this._options=n,this._options.startClosed&&(this._shouldReconnect=!1),this._connect()}return Object.defineProperty(e,"CONNECTING",{get:function(){return 0},enumerable:!0,configurable:!0}),Object.defineProperty(e,"OPEN",{get:function(){return 1},enumerable:!0,configurable:!0}),Object.defineProperty(e,"CLOSING",{get:function(){return 2},enumerable:!0,configurable:!0}),Object.defineProperty(e,"CLOSED",{get:function(){return 3},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"CONNECTING",{get:function(){return e.CONNECTING},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"OPEN",{get:function(){return e.OPEN},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"CLOSING",{get:function(){return e.CLOSING},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"CLOSED",{get:function(){return e.CLOSED},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"binaryType",{get:function(){return this._ws?this._ws.binaryType:this._binaryType},set:function(e){this._binaryType=e,this._ws&&(this._ws.binaryType=e)},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"retryCount",{get:function(){return Math.max(this._retryCount,0)},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"bufferedAmount",{get:function(){return this._messageQueue.reduce(function(e,t){return"string"==typeof t?e+=t.length:t instanceof Blob?e+=t.size:e+=t.byteLength,e},0)+(this._ws?this._ws.bufferedAmount:0)},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"extensions",{get:function(){return this._ws?this._ws.extensions:""},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"protocol",{get:function(){return this._ws?this._ws.protocol:""},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"readyState",{get:function(){return this._ws?this._ws.readyState:this._options.startClosed?e.CLOSED:e.CONNECTING},enumerable:!0,configurable:!0}),Object.defineProperty(e.prototype,"url",{get:function(){return this._ws?this._ws.url:""},enumerable:!0,configurable:!0}),e.prototype.close=function(e,t){void 0===e&&(e=1e3),this._closeCalled=!0,this._shouldReconnect=!1,this._clearTimeouts(),this._ws?this._ws.readyState!==this.CLOSED?this._ws.close(e,t):this._debug("close: already closed"):this._debug("close enqueued: no ws instance")},e.prototype.reconnect=function(e,t){this._shouldReconnect=!0,this._closeCalled=!1,this._retryCount=-1,this._ws&&this._ws.readyState!==this.CLOSED?(this._disconnect(e,t),this._connect()):this._connect()},e.prototype.send=function(e){if(this._ws&&this._ws.readyState===this.OPEN)this._debug("send",e),this._ws.send(e);else{var t=this._options.maxEnqueuedMessages,n=void 0===t?c.maxEnqueuedMessages:t;this._messageQueue.length=e.length&&(e=void 0),{value:e&&e[n++],done:!e}}}}(o),i=r.next();!i.done;i=r.next()){var s=i.value;this._callEventListener(e,s)}}catch(e){t={error:e}}finally{try{i&&!i.done&&(n=r.return)&&n.call(r)}finally{if(t)throw t.error}}return!0},e.prototype.removeEventListener=function(e,t){this._listeners[e]&&(this._listeners[e]=this._listeners[e].filter(function(e){return e!==t}))},e.prototype._debug=function(){for(var e=[],t=0;t"],e))},e.prototype._getNextDelay=function(){var e=this._options,t=e.reconnectionDelayGrowFactor,n=void 0===t?c.reconnectionDelayGrowFactor:t,o=e.minReconnectionDelay,r=void 0===o?c.minReconnectionDelay:o,i=e.maxReconnectionDelay,s=void 0===i?c.maxReconnectionDelay:i,u=0;return this._retryCount>0&&(u=r*Math.pow(n,this._retryCount-1))>s&&(u=s),this._debug("next delay",u),u},e.prototype._wait=function(){var e=this;return new Promise(function(t){setTimeout(t,e._getNextDelay())})},e.prototype._getNextUrl=function(e){if("string"==typeof e)return Promise.resolve(e);if("function"==typeof e){var t=e();if("string"==typeof t)return Promise.resolve(t);if(t.then)return t}throw Error("Invalid URL")},e.prototype._connect=function(){var e=this;if(!this._connectLock&&this._shouldReconnect){this._connectLock=!0;var t=this._options,n=t.maxRetries,o=void 0===n?c.maxRetries:n,r=t.connectionTimeout,i=void 0===r?c.connectionTimeout:r,u=t.WebSocket,a=void 0===u?s():u;if(this._retryCount>=o)this._debug("max retries reached",this._retryCount,">=",o);else{if(this._retryCount++,this._debug("connect",this._retryCount),this._removeListeners(),void 0===(l=a)||!l||2!==l.CLOSING)throw Error("No valid WebSocket class provided");var l;this._wait().then(function(){return e._getNextUrl(e._url)}).then(function(t){e._closeCalled||(e._debug("connect",{url:t,protocols:e._protocols}),e._ws=e._protocols?new a(t,e._protocols):new a(t),e._ws.binaryType=e._binaryType,e._connectLock=!1,e._addListeners(),e._connectTimeout=setTimeout(function(){return e._handleTimeout()},i))})}}},e.prototype._handleTimeout=function(){this._debug("timeout event"),this._handleError(new r(Error("TIMEOUT"),this))},e.prototype._disconnect=function(e,t){if(void 0===e&&(e=1e3),this._clearTimeouts(),this._ws){this._removeListeners();try{this._ws.close(e,t),this._handleClose(new i(e,t,this))}catch(e){}}},e.prototype._acceptOpen=function(){this._debug("accept open"),this._retryCount=0},e.prototype._callEventListener=function(e,t){"handleEvent"in t?t.handleEvent(e):t(e)},e.prototype._removeListeners=function(){this._ws&&(this._debug("removeListeners"),this._ws.removeEventListener("open",this._handleOpen),this._ws.removeEventListener("close",this._handleClose),this._ws.removeEventListener("message",this._handleMessage),this._ws.removeEventListener("error",this._handleError))},e.prototype._addListeners=function(){this._ws&&(this._debug("addListeners"),this._ws.addEventListener("open",this._handleOpen),this._ws.addEventListener("close",this._handleClose),this._ws.addEventListener("message",this._handleMessage),this._ws.addEventListener("error",this._handleError))},e.prototype._clearTimeouts=function(){clearTimeout(this._connectTimeout),clearTimeout(this._uptimeTimeout)},e}()}(); -------------------------------------------------------------------------------- /turbo/static/turbo/js/turbo-django.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | "use strict"; 3 | let protocol = location.protocol.match("https:") ? "wss" : "ws"; 4 | let port = location.port ? ":" + location.port : ""; 5 | const socket = new ReconnectingWebSocket( 6 | `${protocol}://${location.hostname}${port}/ws/` 7 | ); 8 | let counter = 0; 9 | 10 | class TurboChannelsStreamSource extends HTMLElement { 11 | constructor() { 12 | super(); 13 | } 14 | 15 | async connectedCallback() { 16 | Turbo.connectStreamSource(this); 17 | // If connection is already open, just send the subscription 18 | if (socket.readyState === ReconnectingWebSocket.OPEN) { 19 | this.sendSubscription(); 20 | } 21 | 22 | // We also register a Listener to subscripe whenever the stream opens (e.g. after a reconnect 23 | // or if its not connected at first 24 | socket.addEventListener("open", (e) => { 25 | this.sendSubscription(); 26 | }); 27 | 28 | 29 | socket.addEventListener("message", (e) => { 30 | const broadcast = JSON.parse(e.data); 31 | if (broadcast.signed_channel_name === this.channelName) { 32 | this.dispatchMessageEvent(broadcast.data); 33 | } 34 | }); 35 | } 36 | 37 | // Send subscription to the right types of message(s) 38 | sendSubscription() { 39 | socket.send( 40 | JSON.stringify({request_id: this.request_id, type: "subscribe", ...this.subscription}) 41 | ); 42 | } 43 | 44 | disconnectedCallback() { 45 | Turbo.disconnectStreamSource(this); 46 | socket.send( 47 | JSON.stringify({ ...this.subscription, type: "unsubscribe" }) 48 | ); 49 | } 50 | 51 | dispatchMessageEvent(data) { 52 | const event = new MessageEvent("message", { data }); 53 | return this.dispatchEvent(event); 54 | } 55 | 56 | get channelName() { 57 | return this.getAttribute("signed-channel-name"); 58 | } 59 | get args() { 60 | let args = this.getAttribute("args") 61 | if (args){ 62 | return JSON.parse(args); 63 | } 64 | return null 65 | } 66 | get kwargs() { 67 | let kwargs = this.getAttribute("kwargs") 68 | if (kwargs){ 69 | return JSON.parse(kwargs); 70 | } 71 | return null 72 | } 73 | 74 | get subscription() { 75 | return { 76 | signed_channel_name: this.channelName, 77 | args: this.args, 78 | kwargs: this.kwargs, 79 | }; 80 | } 81 | 82 | subscribe() { 83 | socket.send(JSON.stringify({ type: "subscribe", ...this.subscription })); 84 | } 85 | } 86 | 87 | customElements.define( 88 | "turbo-channels-stream-source", 89 | TurboChannelsStreamSource 90 | ); 91 | })(); 92 | -------------------------------------------------------------------------------- /turbo/templates/turbo/components/broadcast_component.html: -------------------------------------------------------------------------------- 1 | {% load turbo_streams %} 2 | {% turbo_subscribe component %} 3 |
      {{initial_render}}
      4 | -------------------------------------------------------------------------------- /turbo/templates/turbo/head.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 8 | 13 | 18 | -------------------------------------------------------------------------------- /turbo/templates/turbo/stream.html: -------------------------------------------------------------------------------- 1 | {% spaceless %} 2 | 3 | 4 | 5 | {% endspaceless %} -------------------------------------------------------------------------------- /turbo/templates/turbo/turbo_stream_source.html: -------------------------------------------------------------------------------- 1 | {% for stream in streams %}{% spaceless %} 2 | 3 | {% endspaceless %}{% endfor %} 4 | -------------------------------------------------------------------------------- /turbo/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hotwire-django/turbo-django/185baaec7941f2abb09c90624c7678ee6260df03/turbo/templatetags/__init__.py -------------------------------------------------------------------------------- /turbo/templatetags/turbo_streams.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.db.models import Model 3 | from django.template import ( 4 | TemplateSyntaxError, 5 | ) 6 | 7 | from turbo.registry import stream_for_stream_name, stream_registry 8 | from turbo.classes import Stream 9 | 10 | register = template.Library() 11 | 12 | 13 | @register.inclusion_tag("turbo/turbo_stream_source.html") 14 | def turbo_subscribe(*stream_items): 15 | stream_names = [] 16 | streams = [] 17 | 18 | for stream_item in stream_items: 19 | 20 | if isinstance(stream_item, Model): 21 | stream = stream_item.stream 22 | elif isinstance(stream_item, Stream): 23 | stream = stream_item 24 | else: 25 | StreamClass = stream_for_stream_name(stream_item) 26 | 27 | if not StreamClass: 28 | stream_names = stream_registry.get_stream_names() 29 | raise TemplateSyntaxError( 30 | "Could not fetch stream with name: '%s' Registered streams: %s" 31 | % (stream_item, stream_names) 32 | ) 33 | continue 34 | stream = StreamClass() 35 | streams.append(stream) 36 | 37 | return {"streams": streams} 38 | 39 | 40 | @register.inclusion_tag("turbo/components/broadcast_component.html", takes_context=True) 41 | def turbo_component(context, component, *args, **kwargs): 42 | if isinstance(component, str): 43 | # figure out component from the string 44 | ComponentClass = stream_for_stream_name(component) 45 | component = ComponentClass(*args, **kwargs) 46 | 47 | initial_render = component.initial_render(context.flatten()) 48 | return {"component": component, "initial_render": initial_render} 49 | --------------------------------------------------------------------------------