├── .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 |
--------------------------------------------------------------------------------