diff --git a/README.md b/README.md index aa57be42..928c33a9 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Tests - Version Info + Version Info License: MIT @@ -20,14 +20,13 @@ interfaces in pure Python. - Binder - # Install Django IDOM ```bash @@ -46,7 +45,7 @@ your_project/ ├── urls.py └── example_app/ ├── __init__.py - ├── idom.py + ├── components.py ├── templates/ │ └── your-template.html └── urls.py @@ -60,7 +59,7 @@ order to create ASGI websockets within Django. Then, we will add a path for IDOM websocket consumer using `IDOM_WEBSOCKET_PATH`. _Note: If you wish to change the route where this websocket is served from, see the -available [settings](#settings.py)._ +available [settings](#settingspy)._ ```python @@ -75,14 +74,14 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_app.settings") # Fetch ASGI application before importing dependencies that require ORM models. http_asgi_app = get_asgi_application() +from channels.auth import AuthMiddlewareStack from channels.routing import ProtocolTypeRouter, URLRouter application = ProtocolTypeRouter( { "http": http_asgi_app, - "websocket": URLRouter( - # add a path for IDOM's websocket - [IDOM_WEBSOCKET_PATH] + "websocket": SessionMiddlewareStack( + AuthMiddlewareStack(URLRouter([IDOM_WEBSOCKET_PATH])) ), } ) @@ -111,6 +110,10 @@ IDOM_BASE_URL: str = "_idom/" # Only applies when not using Django's caching framework (see below). IDOM_WEB_MODULE_LRU_CACHE_SIZE: int | None = None +# Maximum seconds between two reconnection attempts that would cause the client give up. +# 0 will disable reconnection. +IDOM_WS_MAX_RECONNECT_DELAY: int = 604800 + # Configure a cache for loading JS files CACHES = { # Configure a cache for loading JS files for IDOM @@ -147,8 +150,8 @@ ultimately be referenced by name in `your-template.html`. `your-template.html`. import idom @idom.component -def Hello(greeting_recipient): # component names are camelcase by convention - return Header(f"Hello {greeting_recipient}!") +def Hello(websocket, greeting_recipient): # component names are camelcase by convention + return idom.html.header(f"Hello {greeting_recipient}!") ``` ## `example_app/templates/your-template.html` @@ -165,8 +168,6 @@ idom_component module_name.ComponentName param_1="something" param_2="something- In context this will look a bit like the following... ```jinja - -{% load static %} {% load idom %} @@ -184,15 +185,11 @@ You can then serve `your-template.html` from a view just [like any other](https://docs.djangoproject.com/en/3.2/intro/tutorial03/#write-views-that-actually-do-something). ```python -from django.http import HttpResponse -from django.template import loader - +from django.shortcuts import render def your_view(request): context = {} - return HttpResponse( - loader.get_template("your-template.html").render(context, request) - ) + return render(request, "your-template.html", context) ``` ## `example_app/urls.py` diff --git a/src/django_idom/config.py b/src/django_idom/config.py index 9b2d9f0a..1d527a9e 100644 --- a/src/django_idom/config.py +++ b/src/django_idom/config.py @@ -10,6 +10,7 @@ IDOM_BASE_URL = getattr(settings, "IDOM_BASE_URL", "_idom/") IDOM_WEBSOCKET_URL = IDOM_BASE_URL + "websocket/" IDOM_WEB_MODULES_URL = IDOM_BASE_URL + "web_module/" +IDOM_WS_MAX_RECONNECT_DELAY = getattr(settings, "IDOM_WS_MAX_RECONNECT_DELAY", 604800) _CACHES = getattr(settings, "CACHES", {}) if _CACHES: diff --git a/src/django_idom/paths.py b/src/django_idom/paths.py index 62a346e1..98170740 100644 --- a/src/django_idom/paths.py +++ b/src/django_idom/paths.py @@ -2,13 +2,13 @@ from . import views from .config import IDOM_WEB_MODULES_URL, IDOM_WEBSOCKET_URL -from .websocket_consumer import IdomAsyncWebSocketConsumer +from .websocket_consumer import IdomAsyncWebsocketConsumer IDOM_WEBSOCKET_PATH = path( - IDOM_WEBSOCKET_URL + "/", IdomAsyncWebSocketConsumer.as_asgi() + IDOM_WEBSOCKET_URL + "/", IdomAsyncWebsocketConsumer.as_asgi() ) -"""A URL resolver for :class:`IdomAsyncWebSocketConsumer` +"""A URL resolver for :class:`IdomAsyncWebsocketConsumer` While this is relatively uncommon in most Django apps, because the URL of the websocket must be defined by the setting ``IDOM_WEBSOCKET_URL``. There's no need diff --git a/src/django_idom/templates/idom/component.html b/src/django_idom/templates/idom/component.html index 84ec6963..65ad0c45 100644 --- a/src/django_idom/templates/idom/component.html +++ b/src/django_idom/templates/idom/component.html @@ -7,6 +7,7 @@ mountPoint, "{{ idom_websocket_url }}", "{{ idom_web_modules_url }}", + "{{ idom_ws_max_reconnect_delay }}", "{{ idom_component_id }}", "{{ idom_component_params }}" ); diff --git a/src/django_idom/templatetags/idom.py b/src/django_idom/templatetags/idom.py index ba44878c..cd83ebec 100644 --- a/src/django_idom/templatetags/idom.py +++ b/src/django_idom/templatetags/idom.py @@ -10,6 +10,7 @@ IDOM_REGISTERED_COMPONENTS, IDOM_WEB_MODULES_URL, IDOM_WEBSOCKET_URL, + IDOM_WS_MAX_RECONNECT_DELAY, ) @@ -27,6 +28,7 @@ def idom_component(_component_id_, **kwargs): "class": class_, "idom_websocket_url": IDOM_WEBSOCKET_URL, "idom_web_modules_url": IDOM_WEB_MODULES_URL, + "idom_ws_max_reconnect_delay": IDOM_WS_MAX_RECONNECT_DELAY, "idom_mount_uuid": uuid4().hex, "idom_component_id": _component_id_, "idom_component_params": urlencode({"kwargs": json_kwargs}), diff --git a/src/django_idom/websocket_consumer.py b/src/django_idom/websocket_consumer.py index 31f1aa38..349a8366 100644 --- a/src/django_idom/websocket_consumer.py +++ b/src/django_idom/websocket_consumer.py @@ -2,9 +2,12 @@ import asyncio import json import logging -from typing import Any +from dataclasses import dataclass +from typing import Any, Awaitable, Callable, Optional from urllib.parse import parse_qsl +from channels.auth import login +from channels.db import database_sync_to_async as convert_to_async from channels.generic.websocket import AsyncJsonWebsocketConsumer from idom.core.dispatcher import dispatch_single_view from idom.core.layout import Layout, LayoutEvent @@ -15,14 +18,30 @@ _logger = logging.getLogger(__name__) -class IdomAsyncWebSocketConsumer(AsyncJsonWebsocketConsumer): - """Communicates with the browser to perform actions on-demand.""" +@dataclass +class WebsocketConnection: + scope: dict + close: Callable[[Optional[int]], Awaitable[None]] + disconnect: Callable[[int], Awaitable[None]] + view_id: str + - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) +class IdomAsyncWebsocketConsumer(AsyncJsonWebsocketConsumer): + """Communicates with the browser to perform actions on-demand.""" async def connect(self) -> None: await super().connect() + + user = self.scope.get("user") + if user and user.is_authenticated: + try: + await login(self.scope, user) + await convert_to_async(self.scope["session"].save)() + except Exception: + _logger.exception("IDOM websocket authentication has failed!") + elif user is None: + _logger.warning("IDOM websocket is missing AuthMiddlewareStack!") + self._idom_dispatcher_future = asyncio.ensure_future(self._run_dispatch_loop()) async def disconnect(self, code: int) -> None: @@ -41,14 +60,17 @@ async def _run_dispatch_loop(self): try: component_constructor = IDOM_REGISTERED_COMPONENTS[view_id] except KeyError: - _logger.warning(f"Uknown IDOM view ID {view_id!r}") + _logger.warning(f"Unknown IDOM view ID {view_id!r}") return query_dict = dict(parse_qsl(self.scope["query_string"].decode())) component_kwargs = json.loads(query_dict.get("kwargs", "{}")) + # Provide developer access to parts of this websocket + socket = WebsocketConnection(self.scope, self.close, self.disconnect, view_id) + try: - component_instance = component_constructor(**component_kwargs) + component_instance = component_constructor(socket, **component_kwargs) except Exception: _logger.exception( f"Failed to construct component {component_constructor} " diff --git a/src/js/package-lock.json b/src/js/package-lock.json index ba26a073..6869fe24 100644 --- a/src/js/package-lock.json +++ b/src/js/package-lock.json @@ -70,9 +70,9 @@ "integrity": "sha512-L0s3Sid5r6YwrEvkig14SK3Emmc+kIjlfLhEGn2Vy3bk21JyDEes4MoDsbJk6luaPp8bugErnxPz86ZuAw6e5Q==" }, "idom-client-react": { - "version": "0.33.2", - "resolved": "https://registry.npmjs.org/idom-client-react/-/idom-client-react-0.33.2.tgz", - "integrity": "sha512-B0dIWTYtmSwWUkA7g9/8Stz0Otar/pSaARdxxaPVL8QGvjxaCEnP0M1jklep2SXlbO1U/mXRYIYWgGE2sSlTLg==", + "version": "0.33.3", + "resolved": "https://registry.npmjs.org/idom-client-react/-/idom-client-react-0.33.3.tgz", + "integrity": "sha512-CTqnSzhAVz20IHDexECtCdLuvE2diUIi63kog45sNiJdu5ig+cUKjT4zuA1YHGchf4jJVgsQKPsd1BB3Bx6cew==", "requires": { "fast-json-patch": "^3.0.0-1", "htm": "^3.0.3" diff --git a/src/js/package.json b/src/js/package.json index bea68d7a..ea04bf2d 100644 --- a/src/js/package.json +++ b/src/js/package.json @@ -18,7 +18,7 @@ "rollup-plugin-replace": "^2.2.0" }, "dependencies": { - "idom-client-react": "^0.33.0", + "idom-client-react": "^0.33.3", "react": "^17.0.2", "react-dom": "^17.0.2" } diff --git a/src/js/src/index.js b/src/js/src/index.js index bddf2a55..8f17c0d4 100644 --- a/src/js/src/index.js +++ b/src/js/src/index.js @@ -14,18 +14,24 @@ export function mountViewToElement( mountPoint, idomWebsocketUrl, idomWebModulesUrl, + maxReconnectTimeout, viewId, queryParams ) { const fullWebsocketUrl = WS_ENDPOINT_URL + idomWebsocketUrl + viewId + "/?" + queryParams; - const fullWebModulesUrl = LOCATION.origin + "/" + idomWebModulesUrl + const fullWebModulesUrl = LOCATION.origin + "/" + idomWebModulesUrl; const loadImportSource = (source, sourceType) => { return import( sourceType == "NAME" ? `${fullWebModulesUrl}${source}` : source ); }; - mountLayoutWithWebSocket(mountPoint, fullWebsocketUrl, loadImportSource); + mountLayoutWithWebSocket( + mountPoint, + fullWebsocketUrl, + loadImportSource, + maxReconnectTimeout + ); } diff --git a/tests/test_app/asgi.py b/tests/test_app/asgi.py index dcb8112c..2b711cc1 100644 --- a/tests/test_app/asgi.py +++ b/tests/test_app/asgi.py @@ -19,12 +19,16 @@ # Fetch ASGI application before importing dependencies that require ORM models. http_asgi_app = get_asgi_application() +from channels.auth import AuthMiddlewareStack # noqa: E402 from channels.routing import ProtocolTypeRouter, URLRouter # noqa: E402 +from channels.sessions import SessionMiddlewareStack # noqa: E402 application = ProtocolTypeRouter( { "http": http_asgi_app, - "websocket": URLRouter([IDOM_WEBSOCKET_PATH]), + "websocket": SessionMiddlewareStack( + AuthMiddlewareStack(URLRouter([IDOM_WEBSOCKET_PATH])) + ), } ) diff --git a/tests/test_app/components.py b/tests/test_app/components.py index f242b9f1..d3452efb 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -2,12 +2,12 @@ @idom.component -def HelloWorld(): +def HelloWorld(websocket): return idom.html.h1({"id": "hello-world"}, "Hello World!") @idom.component -def Button(): +def Button(websocket): count, set_count = idom.hooks.use_state(0) return idom.html.div( idom.html.button( @@ -22,7 +22,7 @@ def Button(): @idom.component -def ParametrizedComponent(x, y): +def ParametrizedComponent(websocket, x, y): total = x + y return idom.html.h1({"id": "parametrized-component", "data-value": total}, total) @@ -32,5 +32,5 @@ def ParametrizedComponent(x, y): @idom.component -def SimpleBarChart(): +def SimpleBarChart(websocket): return VictoryBar() diff --git a/tests/test_app/settings.py b/tests/test_app/settings.py index ac7d6ab9..29eadffa 100644 --- a/tests/test_app/settings.py +++ b/tests/test_app/settings.py @@ -26,7 +26,7 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = ["*"] # Application definition INSTALLED_APPS = [ diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html index 1ad73c4a..5b2311d2 100644 --- a/tests/test_app/templates/base.html +++ b/tests/test_app/templates/base.html @@ -1,23 +1,22 @@ {% load static %} {% load idom %} - - - - - - IDOM - - -

IDOM Test Page

-
{% idom_component "test_app.components.HelloWorld" class="hello-world" %}
-
{% idom_component "test_app.components.Button" class="button" %}
-
{% idom_component "test_app.components.ParametrizedComponent" class="parametarized-component" x=123 y=456 %}
-
{% idom_component "test_app.components.SimpleBarChart" class="simple-bar-chart" %}
- + + + + + + IDOM + + + +

IDOM Test Page

+
{% idom_component "test_app.components.HelloWorld" class="hello-world" %}
+
{% idom_component "test_app.components.Button" class="button" %}
+
{% idom_component "test_app.components.ParametrizedComponent" class="parametarized-component" x=123 y=456 %} +
+
{% idom_component "test_app.components.SimpleBarChart" class="simple-bar-chart" %}
+ + diff --git a/tests/test_app/urls.py b/tests/test_app/urls.py index cd4f3da0..1ea0c99b 100644 --- a/tests/test_app/urls.py +++ b/tests/test_app/urls.py @@ -17,6 +17,7 @@ 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ +from django.contrib import admin from django.urls import path from django_idom import IDOM_WEB_MODULES_PATH @@ -24,4 +25,8 @@ from .views import base_template -urlpatterns = [path("", base_template), IDOM_WEB_MODULES_PATH] +urlpatterns = [ + path("", base_template), + IDOM_WEB_MODULES_PATH, + path("admin/", admin.site.urls), +] diff --git a/tests/test_app/views.py b/tests/test_app/views.py index 248235a7..11874908 100644 --- a/tests/test_app/views.py +++ b/tests/test_app/views.py @@ -1,7 +1,6 @@ -from django.http import HttpResponse -from django.template import loader +from django.shortcuts import render def base_template(request): context = {} - return HttpResponse(loader.get_template("base.html").render(context, request)) + return render(request, "base.html", context)