├── .github ├── FUNDING.yml └── workflows │ └── python-publish.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.rst ├── __init__.py ├── liveview ├── apps.py ├── consumers.py ├── context_processors.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── routing.py └── utils.py ├── pyproject.toml ├── requirements.txt └── setup.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: tanrax 2 | ko_fi: androsfenollosa 3 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | deploy: 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Set up Python 26 | uses: actions/setup-python@v3 27 | with: 28 | python-version: '3.x' 29 | - name: Install dependencies 30 | run: | 31 | python3 -m pip install --upgrade pip 32 | pip3 install -r requirements.txt 33 | - name: Add version 34 | run: sed -i 's/VERSION/${{github.ref_name}}/g' setup.py 35 | - name: Build package 36 | run: python3 setup.py sdist bdist_wheel 37 | - name: Publish package 38 | run: twine upload --non-interactive --username ${{ secrets.USER }} --password ${{ secrets.PASSWORD }} dist/* 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build/ 2 | /dist/ 3 | /django_liveview.egg-info/ 4 | /liveview/migrations/__pycache__/ 5 | /venv/ 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2007 Michael Trier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Django LiveView 3 | ===== 4 | 5 | Framework for creating a complete HTML over the Wire site or LiveView 6 | ######## 7 | 8 | .. image:: https://github.com/Django-LiveView/starter-template/raw/main/brand_assets/github%20social%20preview.jpg 9 | :width: 100% 10 | :alt: Alternative text 11 | 12 | Among its superpowers you can find 13 | ********************** 14 | 15 | - Create SPAs without using APIs. 16 | - Uses Django's template system to render the frontend (Without JavaScript). 17 | - The logic is not split between the backend and the frontend, it all stays in Python. 18 | - You can still use all of Django's native tools, such as its ORM, forms, plugins, etc. 19 | - Everything is asynchronous by default. 20 | - Don't learn anything new. If you know Python, you know how to use Django LiveView. 21 | - All in real time. 22 | 23 | System components communicate through realtime events, where events represent important actions. Every components can produce and consume actions, allowing asynchronous and decoupled communication. 24 | 25 | LiveView is a Django application for creating a dynamic website using HTML over WebSockets. 26 | 27 | Example template: https://github.com/Django-LiveView/starter-template 28 | 29 | Quick start 30 | ----------- 31 | 32 | 1. Add "liveview", "daphne" and "channels" to your INSTALLED_APPS setting like this:: 33 | 34 | INSTALLED_APPS = [ 35 | "daphne", 36 | "channels", 37 | "liveview", 38 | ..., 39 | ] 40 | 41 | 2. Include, in settings.py, the Apps that will use LiveView:: 42 | 43 | LIVEVIEW_APPS = ["website"] 44 | 45 | 3. Run ``python manage.py migrate`` to create the LiveView models. 46 | 47 | 4. Start the development server and visit http://127.0.0.1:8000/ 48 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Django-LiveView/liveview/faa249836f5a7a6cc935df7317f0777eabe3ce07/__init__.py -------------------------------------------------------------------------------- /liveview/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class LiveViewConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "liveview" 7 | -------------------------------------------------------------------------------- /liveview/consumers.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import threading 4 | from django.conf import settings 5 | from channels.generic.websocket import AsyncJsonWebsocketConsumer 6 | from channels.db import database_sync_to_async 7 | from .models import Client 8 | from asgiref.sync import async_to_sync, sync_to_async 9 | from channels.layers import get_channel_layer 10 | from django.template.loader import render_to_string 11 | 12 | 13 | # Load all actions from settings.LIVEVIEW_APPS 14 | for current_app in settings.LIVEVIEW_APPS: 15 | for entry in os.scandir(os.path.join(settings.BASE_DIR, current_app.replace(".", "/"), "actions")): 16 | if entry.is_file(): 17 | name = entry.name.split(".")[0] 18 | exec(f"from {current_app}.actions import {name} as {name}") 19 | 20 | 21 | class LiveViewConsumer(AsyncJsonWebsocketConsumer): 22 | 23 | channel_name_broadcast = "broadcast" 24 | 25 | @database_sync_to_async 26 | def create_client(self, channel_name): 27 | Client.objects.create(channel_name=channel_name) 28 | 29 | @database_sync_to_async 30 | def delete_client(self, channel_name): 31 | Client.objects.filter(channel_name=channel_name).delete() 32 | 33 | async def connect(self): 34 | """Event when client connects""" 35 | # Accept the connection 36 | await self.accept() 37 | # Add to group broadcast 38 | await self.channel_layer.group_add( 39 | self.channel_name_broadcast, self.channel_name 40 | ) 41 | # Save the client 42 | await self.create_client(self.channel_name) 43 | 44 | async def disconnect(self, close_code): 45 | """Event when client disconnects""" 46 | # Remove from group broadcast 47 | await self.channel_layer.group_discard( 48 | self.channel_name_broadcast, self.channel_name 49 | ) 50 | # Delete the client 51 | await self.delete_client(self.channel_name) 52 | 53 | async def receive_json(self, data_received): 54 | """ 55 | Event when data is received 56 | All information will arrive in 2 variables: 57 | "action", with the action to be taken 58 | "data" with the information 59 | """ 60 | # Get the data 61 | data = data_received if "data" in data_received else None 62 | # Depending on the action we will do one task or another. 63 | # Example: If the action is "home->search", we will call the function "actions.home.search" with the data 64 | if data and "action" in data: 65 | action_data = data["action"].split("->") 66 | if len(action_data) == 2: 67 | action = action_data[0].lower() 68 | function = action_data[1].lower() 69 | try: 70 | await eval(f"{action}.{function}(self, data)") 71 | except Exception as e: 72 | print(f"Bad action: {data['action']}") 73 | # Print the error 74 | exc_type, exc_obj, exc_tb = sys.exc_info() 75 | print(exc_type) 76 | 77 | async def send_html(self, data, broadcast=False): 78 | """Event: Send html to client 79 | 80 | Example minimum data: 81 | { 82 | "action": "home->search", 83 | "selector": "#search-results", 84 | "html": "

Example

" 85 | } 86 | 87 | Example with optional data: 88 | { 89 | "action": "home->search", 90 | "selector": "#search-results", 91 | "html": "

Example

", 92 | "append": true, # Optional, default: false. If true, the html will be added, not replaced 93 | "url": "/search/results", # Optional, default: None. If set, the url will be changed 94 | "title": "Search results", # Optional, default: None. If set, the title will be changed 95 | "scroll": true # Optional, default: false. If true, the page will be scrolled to the selector 96 | "scrollTop": false # Optional, default: false. If true, the page will be scrolled to the top 97 | } 98 | """ 99 | if "selector" in data and "html" in data: 100 | # Required data 101 | my_data = { 102 | "action": data["action"], 103 | "selector": data["selector"], 104 | "html": data["html"], 105 | } 106 | # Optional data 107 | if "append" in data: 108 | my_data.update({"append": data["append"]}) 109 | else: 110 | my_data.update({"append": False}) 111 | if "url" in data: 112 | my_data.update({"url": data["url"]}) 113 | if "title" in data: 114 | my_data.update({"title": data["title"]}) 115 | if "scroll" in data: 116 | my_data.update({"scroll": data["scroll"]}) 117 | if "scrollTop" in data: 118 | my_data.update({"scrollTop": data["scrollTop"]}) 119 | # Send the data 120 | if broadcast: 121 | if hasattr(self, "channel_layer"): 122 | await self.channel_layer.group_send( 123 | self.channel_name_broadcast, 124 | {"type": "send_data_to_frontend", "data": my_data}, 125 | ) 126 | else: 127 | await self.send_data_to_frontend(my_data) 128 | 129 | async def send_data_to_frontend(self, data): 130 | """Send data to the frontend""" 131 | # Corrects the data if it comes from an external call or a group_send 132 | send_data = data["data"] if "type" in data else data 133 | await self.send_json(send_data) 134 | -------------------------------------------------------------------------------- /liveview/context_processors.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from channels.auth import get_user 3 | from asgiref.sync import async_to_sync 4 | 5 | 6 | def get_global_context(consumer=None): 7 | """Return a dictionary of global context variables.""" 8 | return { 9 | "DEBUG": settings.DEBUG, 10 | "user": consumer.scope["user"] 11 | if consumer and "user" in consumer.scope 12 | else None, 13 | } 14 | 15 | 16 | def customs(request): 17 | """Return a dictionary of context variables.""" 18 | context = get_global_context() 19 | # Fix for admin site 20 | context.pop("user") 21 | return context 22 | -------------------------------------------------------------------------------- /liveview/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.5 on 2023-09-29 16:52 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Client', 19 | fields=[ 20 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('channel_name', models.CharField(blank=True, default=None, max_length=200, null=True)), 22 | ('created_at', models.DateTimeField(auto_now_add=True)), 23 | ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 24 | ], 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /liveview/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Django-LiveView/liveview/faa249836f5a7a6cc935df7317f0777eabe3ce07/liveview/migrations/__init__.py -------------------------------------------------------------------------------- /liveview/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth.models import User 3 | from django.conf import settings 4 | from django.utils.text import slugify 5 | 6 | 7 | class Client(models.Model): 8 | """ 9 | Each client who is connected to the website 10 | """ 11 | 12 | user = models.ForeignKey(User, on_delete=models.CASCADE, null=True) 13 | channel_name = models.CharField(max_length=200, blank=True, null=True, default=None) 14 | created_at = models.DateTimeField(auto_now_add=True) 15 | 16 | def __str__(self): 17 | return self.user.username if self.user else self.channel_name 18 | -------------------------------------------------------------------------------- /liveview/routing.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Django-LiveView/liveview/faa249836f5a7a6cc935df7317f0777eabe3ce07/liveview/routing.py -------------------------------------------------------------------------------- /liveview/utils.py: -------------------------------------------------------------------------------- 1 | from threading import Thread 2 | from asgiref.sync import async_to_sync 3 | import time 4 | from django.template.loader import render_to_string 5 | from .context_processors import get_global_context 6 | from asgiref.sync import sync_to_async 7 | from django.utils.translation import activate as translation_activate 8 | from django.conf import settings 9 | from django.core.mail import EmailMultiAlternatives 10 | from uuid import uuid4 11 | import base64 12 | from django.core.files import File 13 | from tempfile import NamedTemporaryFile 14 | 15 | 16 | async def get_html(template, context={}): 17 | """Get html from template.""" 18 | return await sync_to_async(render_to_string)(template, context) 19 | 20 | 21 | def set_language(language="en"): 22 | """Set the language.""" 23 | if language: 24 | translation_activate(language) 25 | 26 | 27 | def enable_lang(func): 28 | """Decorator: Enable language""" 29 | 30 | def wrapper(*args, **kwargs): 31 | lang = args[1]["data"].get("lang", settings.LANGUAGE_CODE) 32 | set_language(lang) 33 | kwargs["lang"] = lang 34 | return func(*args, **kwargs) 35 | 36 | return wrapper 37 | 38 | 39 | async def toggle_loading(consumer, show=False): 40 | """Toogle the footer form.""" 41 | # html = await get_html(template, get_global_context(consumer=consumer)) 42 | data = { 43 | "action": ("Show" if show else "Hide") + " loading", 44 | "selector": "#loading", 45 | "html": render_to_string( 46 | "components/_loading.html", get_global_context(consumer=consumer) 47 | ) 48 | if show 49 | else "", 50 | } 51 | await consumer.send_html(data) 52 | 53 | 54 | def loading(func): 55 | """Decorator: Show loading.""" 56 | 57 | async def wrapper(*args, **kwargs): 58 | await toggle_loading(args[0], True) 59 | result = await func(*args, **kwargs) 60 | await toggle_loading(args[0], False) 61 | return result 62 | 63 | return wrapper 64 | 65 | 66 | async def update_active_nav(consumer, page): 67 | """Update the active nav item in the navbar.""" 68 | context = get_global_context(consumer=consumer) 69 | context["active_nav"] = page 70 | data = { 71 | "action": "Update active nav", 72 | "selector": "#content-header", 73 | "html": render_to_string("components/_header.html", context), 74 | } 75 | await consumer.send_html(data) 76 | 77 | 78 | def send_email( 79 | subject="", to=[], template_txt="", template_html="", data={}, attachments=[] 80 | ): 81 | """Send email""" 82 | msg = EmailMultiAlternatives( 83 | subject, 84 | render_to_string(template_txt, data | {"settings": settings}), 85 | settings.DEFAULT_FROM_EMAIL, 86 | to, 87 | ) 88 | msg.attach_alternative( 89 | render_to_string(template_html, data | {"settings": settings}), "text/html" 90 | ) 91 | for attachment in attachments: 92 | msg.attach_file(attachment) 93 | return msg.send() 94 | 95 | 96 | async def send_notification(consumer: object, message: str, level: str = "info"): 97 | """Send notification.""" 98 | # Variables 99 | uuid = str(uuid4()) 100 | timeout = 3000 # ms 101 | 102 | async def make_notification(consumer=None, uuid="", level="", message=""): 103 | # Show message 104 | context = get_global_context(consumer=consumer) 105 | context.update({"id": uuid, "message": message, "level": level}) 106 | html = await get_html("components/_notification.html", context) 107 | data = { 108 | "action": "new_notification", 109 | "selector": "#notifications", 110 | "html": html, 111 | "append": True, 112 | } 113 | await consumer.send_html(data) 114 | 115 | # Remove message async 116 | def remove_notification(consumer=None, uuid="", timeout=0): 117 | time.sleep(timeout / 1000) 118 | data = { 119 | "action": "delete_notification", 120 | "selector": f"#notifications > #notifications__item-{uuid}", 121 | "html": "", 122 | } 123 | async_to_sync(consumer.send_html)(data) 124 | 125 | # Tasks 126 | await make_notification(consumer, uuid, level, message) 127 | # Run in background the remove notification, sleep 3 seconds 128 | Thread(target=remove_notification, args=(consumer, uuid, timeout)).start() 129 | 130 | 131 | def get_image_from_base64(base64_string: str, mime_type: str, is_file: bool = True): 132 | """Get image from base64 string. 133 | Args: 134 | base64_string (str): Base64 string. 135 | mime_type (str): Mime type. Example: image/jpeg. 136 | is_file (bool): Return a file or bytes. 137 | 138 | Returns: 139 | File or bytes: Image. 140 | str: Filename. 141 | """ 142 | if mime_type in ( 143 | "image/jpeg", 144 | "image/png", 145 | "image/webp", 146 | ): 147 | # Variables 148 | uuid = str(uuid4()) 149 | extension = mime_type.split("/")[-1] 150 | # Str base64 to bytes 151 | base64_img_bytes = base64_string.encode("utf-8") 152 | decoded_image_data = base64.decodebytes(base64_img_bytes) 153 | my_filename = f"{uuid}.{extension}" 154 | if is_file: 155 | # Bytes to file 156 | img_temp = NamedTemporaryFile(delete=True) 157 | img_temp.write(decoded_image_data) 158 | img_temp.flush() 159 | return File(img_temp), my_filename 160 | else: 161 | return decoded_image_data, my_filename 162 | return None, None 163 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ['setuptools>=40.8.0'] 3 | build-backend = 'setuptools.build_meta' -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | wheel===0.38.3 2 | twine===4.0.1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name="django-liveview", 5 | py_modules=["liveview"], 6 | version="1.0.5", 7 | python_requires=">3.7", 8 | description="Framework for creating Realtime SPAs using HTML over the Wire technology in Django", 9 | author="Andros Fenollosa", 10 | author_email="andros@fenollosa.email", 11 | url="https://django-liveview.andros.dev/", 12 | license="MIT License", 13 | platforms=["any"], 14 | packages=["liveview", "liveview.migrations"], 15 | keywords=["django", "ssr", "channels", "liveview", "html-over-the-wire", "hotwire"], 16 | classifiers=[ 17 | "Programming Language :: Python :: 3", 18 | "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", 19 | "Operating System :: OS Independent", 20 | ], 21 | install_requires=["channels", "django", "channels_redis"], 22 | entry_points="", 23 | ) 24 | --------------------------------------------------------------------------------