Skip to content

Commit c2bdc18

Browse files
Merge pull request #29 from delsim/doc_live_updating
Documentation for live updating
2 parents b94e432 + 91a1530 commit c2bdc18

14 files changed

+223
-23
lines changed

demo/demo/plotly_apps.py

+29-10
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ def callback_c(*args, **kwargs):
134134
html.Div(id='button_local_counter', children="Press any button to start"),
135135
], className="")
136136

137+
#pylint: disable=too-many-arguments
137138
@liveIn.expanded_callback(
138139
dash.dependencies.Output('button_local_counter', 'children'),
139140
[dash.dependencies.Input('red-button', 'n_clicks'),
@@ -186,10 +187,10 @@ def callback_liveIn_button_press(red_clicks, blue_clicks, green_clicks,
186187
datetime.fromtimestamp(0.001*timestamp))
187188

188189
liveOut = DjangoDash("LiveOutput",
189-
)#serve_locally=True)
190+
)#serve_locally=True)
190191

191192
def _get_cache_key(state_uid):
192-
return "demo-liveout-s4-%s" % state_uid
193+
return "demo-liveout-s6-%s" % state_uid
193194

194195
def generate_liveOut_layout():
195196
'Generate the layout per-app, generating each tine a new uuid for the state_uid argument'
@@ -210,6 +211,7 @@ def generate_liveOut_layout():
210211

211212
liveOut.layout = generate_liveOut_layout
212213

214+
#pylint: disable=unused-argument
213215
#@liveOut.expanded_callback(
214216
@liveOut.callback(
215217
dash.dependencies.Output('internal_state', 'children'),
@@ -242,6 +244,18 @@ def callback_liveOut_pipe_in(named_count, state_uid, **kwargs):
242244
colour_set = [(None, 0, 100) for i in range(5)]
243245

244246
_, last_ts, prev = colour_set[-1]
247+
248+
# Loop over all existing timestamps and find the latest one
249+
if not click_timestamp or click_timestamp < 1:
250+
click_timestamp = 0
251+
252+
for _, the_colour_set in state.items():
253+
_, lts, _ = the_colour_set[-1]
254+
if lts > click_timestamp:
255+
click_timestamp = lts
256+
257+
click_timestamp = click_timestamp + 1000
258+
245259
if click_timestamp > last_ts:
246260
colour_set.append((user, click_timestamp, prev * random.lognormvariate(0.0, 0.1)),)
247261
colour_set = colour_set[-100:]
@@ -268,23 +282,28 @@ def callback_show_timeseries(internal_state_string, state_uid, **kwargs):
268282

269283
colour_series = {}
270284

285+
colors = {'red':'#FF0000',
286+
'blue':'#0000FF',
287+
'green':'#00FF00',
288+
'yellow': '#FFFF00',
289+
'cyan': '#00FFFF',
290+
'magenta': '#FF00FF',
291+
'black' : '#000000',
292+
}
293+
271294
for colour, values in state.items():
272295
timestamps = [datetime.fromtimestamp(int(0.001*ts)) for _, ts, _ in values if ts > 0]
273-
users = [user for user, ts, _ in values if ts > 0]
296+
#users = [user for user, ts, _ in values if ts > 0]
274297
levels = [level for _, ts, level in values if ts > 0]
275-
colour_series[colour] = pd.Series(levels, index=timestamps).groupby(level=0).first()
298+
if colour in colors:
299+
colour_series[colour] = pd.Series(levels, index=timestamps).groupby(level=0).first()
276300

277301
df = pd.DataFrame(colour_series).fillna(method="ffill").reset_index()[-25:]
278302

279-
colors = {'red':'#FF0000',
280-
'blue':'#0000FF',
281-
'green':'#00FF00',
282-
}
283-
284303
traces = [go.Scatter(y=df[colour],
285304
x=df['index'],
286305
name=colour,
287-
line=dict(color=colors[colour]),
306+
line=dict(color=colors.get(colour, '#000000')),
288307
) for colour in colour_series]
289308

290309
return {'data':traces,

demo/demo/settings.py

+6
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,8 @@
125125
"ws_route" : "ws/channel",
126126

127127
"insert_demo_migrations" : True, # Insert model instances used by the demo
128+
129+
"http_poke_enabled" : True, # Flag controlling availability of direct-to-messaging http endpoint
128130
}
129131

130132
# Static files (CSS, JavaScript, Images)
@@ -165,6 +167,10 @@
165167
# can be useful for development especially if offline - we add in the root directory
166168
# of each module. This is a bit of fudge and only needed if serve_locally=True is
167169
# set on a DjangoDash instance.
170+
#
171+
# Note that this makes all of the python module (including .py and .pyc) files available
172+
# through the static route. This is not a big deal for development but at the same time
173+
# not particularly neat or tidy.
168174

169175
if DEBUG:
170176

demo/demo/templates/base.html

+3
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
<a class="nav-item nav-link btn btn-lg" href="{%url "demo-two"%}">Demo Two - Initial State</a>
2626
<a class="nav-item nav-link btn btn-lg" href="{%url "demo-three"%}">Demo Three - Enhanced Callbacks</a>
2727
<a class="nav-item nav-link btn btn-lg" href="{%url "demo-four"%}">Demo Four - Live Updating</a>
28+
<a class="nav-item nav-link btn btn-lg"
29+
target="_blank"
30+
href="https://django-plotly-dash.readthedocs.io/en/latest/">Online Documentation</a>
2831
{%endblock%}
2932
</div>
3033
</nav>

demo/demo/templates/demo_four.html

+13
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,17 @@ <h1>Live Updating</h1>
4141
{%plotly_app slug="liveoutput-2" ratio=0.5 %}
4242
</div>
4343
</div>
44+
<p>
45+
</p>
46+
<p>
47+
Any http command
48+
can be used to send a message to the apps. This is equiavent to a press of
49+
the red button. Other colours can be specified, including yellow, cyan and black in
50+
addition to the three named in the LiveInput app.
51+
</p>
52+
<div class="card bg-light border-dark">
53+
<div class="card-body">
54+
curl http://localhost:8000/dpd/views/poke/ -d'{"channel_name":"live_button_counter","label":"named_counts","value":{"click_colour":"red"}}'
55+
</div>
56+
</div>
4457
{%endblock%}

demo/demo/urls.py

+4
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
# Load demo plotly apps - this triggers their registration
2929
import demo.plotly_apps # pylint: disable=unused-import
3030

31+
from django_plotly_dash.views import add_to_session
32+
3133
urlpatterns = [
3234
url('^$', TemplateView.as_view(template_name='index.html'), name="home"),
3335
url('^demo-one$', TemplateView.as_view(template_name='demo_one.html'), name="demo-one"),
@@ -36,6 +38,8 @@
3638
url('^demo-four$', TemplateView.as_view(template_name='demo_four.html'), name="demo-four"),
3739
url('^admin/', admin.site.urls),
3840
url('^django_plotly_dash/', include('django_plotly_dash.urls')),
41+
42+
url('^demo-session-var$', add_to_session, name="session-variable-example"),
3943
]
4044

4145
# Add in static routes so daphne can serve files; these should

django_plotly_dash/routing.py

+11-3
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,19 @@
2929
from django.conf.urls import url
3030

3131
from .consumers import MessageConsumer, PokePipeConsumer
32-
from .util import pipe_ws_endpoint_name, http_endpoint
32+
from .util import pipe_ws_endpoint_name, http_endpoint, http_poke_endpoint_enabled
3333

3434
# TODO document this and discuss embedding with other routes
35+
36+
http_routes = [
37+
]
38+
39+
if http_poke_endpoint_enabled():
40+
http_routes.append(url(http_endpoint("poke"), PokePipeConsumer))
41+
42+
http_routes.append(url("^", AsgiHandler)) # AsgiHandler is 'the normal Django view handlers'
43+
3544
application = ProtocolTypeRouter({
3645
'websocket': AuthMiddlewareStack(URLRouter([url(pipe_ws_endpoint_name(), MessageConsumer),])),
37-
'http': AuthMiddlewareStack(URLRouter([url(http_endpoint("poke"), PokePipeConsumer),
38-
url("^", AsgiHandler),])), # AsgiHandler is 'the normal Django view handlers'
46+
'http': AuthMiddlewareStack(URLRouter(http_routes)),
3947
})

django_plotly_dash/util.py

+4
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,7 @@ def insert_demo_migrations():
4949
'Check settings and report if objects for demo purposes should be inserted during migration'
5050

5151
return _get_settings().get('insert_demo_migrations', False)
52+
53+
def http_poke_endpoint_enabled():
54+
'Return true if the http endpoint is enabled through the settings'
55+
return _get_settings().get('http_poke_enabled', True)

django_plotly_dash/views.py

+15
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,18 @@ def component_suites(request, resource=None, component=None, **kwargs):
106106
redone_url = "/static/dash/%s/%s" %(component, resource)
107107

108108
return HttpResponseRedirect(redirect_to=redone_url)
109+
110+
111+
# pylint: disable=wrong-import-position, wrong-import-order
112+
from django.template.response import TemplateResponse
113+
114+
def add_to_session(request, template_name="index.html", **kwargs):
115+
'Add some info to a session in a place that django-plotly-dash can pass to a callback'
116+
117+
django_plotly_dash = request.session.get("django_plotly_dash", dict())
118+
119+
session_add_count = django_plotly_dash.get('add_counter', 0)
120+
django_plotly_dash['add_counter'] = session_add_count + 1
121+
request.session['django_plotly_dash'] = django_plotly_dash
122+
123+
return TemplateResponse(request, template_name, {})

docs/configuration.rst

+3
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ below.
1616
# Route used for direct http insertion of pipe messages
1717
"http_route" : "dpd/views",
1818
19+
# Flag controlling existince of http poke endpoint
20+
"http_poke_enabled" : True,
21+
1922
# Insert data for the demo when migrating
2023
"insert_demo_migrations" : False,
2124
}

docs/dash_components.rst

+31-2
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,39 @@
33
Dash components
44
===============
55

6-
The ``dpd-components`` package contains ``Dash`` components.
6+
The ``dpd-components`` package contains ``Dash`` components. This package is installed as a
7+
dependency of ``django-plotly-dash``.
78

89
.. _pipe_component:
910
The ``Pipe`` component
1011
--------------
1112

12-
Blah
13+
Each ``Pipe`` component instance listens for messages on a single channel. The ``value`` member of any message on that channel whose ``label`` matches
14+
that of the component will be used to update the ``value`` property of the component. This property can then be used in callbacks like
15+
any other ``Dash`` component property.
16+
17+
An example, from the demo application:
18+
19+
.. code-block:: python
20+
21+
import dpd_components as dpd
22+
23+
app.layout = html.Div([
24+
...
25+
dpd.Pipe(id="named_count_pipe", # ID in callback
26+
value=None, # Initial value prior to any message
27+
label="named_counts", # Label used to identify relevant messages
28+
channel_name="live_button_counter"), # Channel whose messages are to be examined
29+
...
30+
])
31+
32+
The ``value`` of the message is sent from the server to all front ends with ``Pipe`` components listening
33+
on the given ``channel_name``. This means that this part of the message should be small, and it must
34+
be JSON serialisable. Also, there is no guarantee that any callbacks will be executed in the same Python
35+
process as the one that initiated the initial message from server to front end.
36+
37+
The ``Pipe`` properties can be persisted like any other ``DashApp`` instance, although it is unlikely
38+
that continued persistence of state on each update of this component is likely to be useful.
39+
40+
This component requires a bidirectional connection, such as a websocket, to the server. Inserting
41+
a ``plotly_message_pipe`` :ref:`template tag <plotly_message_pipe>` is sufficient.

docs/installation.rst

+1-2
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@
33
Installation
44
============
55

6-
The package requires version 2.0 or greater of Django, essentially due to the use of the ``path`` function for
7-
registering routes. The minimum Python version needed is 3.5.
6+
The package requires version 2.0 or greater of Django, and a minimum Python version needed of 3.5.
87

98
Use ``pip`` to install the package, preferably to a local ``virtualenv``::
109

docs/introduction.rst

+3-1
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,6 @@ above for stateless applications.
3131
Also, an enhanced version of the ``Dash`` callback is provided, giving the callback access to the current User, the current session, and also
3232
the model instance associated with the application's internal state.
3333

34-
This package is compatible with version 2.0 onwards of Django, as it uses the new path registration functionality.
34+
This package is compatible with version 2.0 onwards of Django. Use of the :ref:`live updating <updating>` feature requires
35+
the Django Channels extension; in turn this requires a suitable messaging backend such as Redis.
36+

docs/template_tags.rst

+2
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ At least one of ``da``, ``slug`` or ``name`` must be provided. An object identif
3636
identified by ``name`` will be. If either of these arguments are provided, they must resolve to valid objects even if
3737
not used. If neither are provided, then the model instance in ``da`` will be used.
3838

39+
.. _plotly_message_pipe:
40+
3941
The ``plotly_message_pipe`` template tag
4042
----------------------------------------
4143

docs/updating.rst

+98-5
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,110 @@
33
Live updating
44
=============
55

6-
Live updating blah.
6+
Live updating is supported using additional ``Dash`` :ref:`components <dash_components>` and
7+
leveraging `Django Channels <https://channels.readthedocs.io/en/latest/>`_ to provide websocket endpoints.
8+
9+
Server-initiated messages are sent to all interested clients. The content of the message is then injected into
10+
the application from the client, and from that point it is handled like any other value passed to a callback function.
11+
The messages are constrained to be JSON serialisable, as that is how they are transmitted to and from the clients, and should
12+
also be as small as possible given that they travel from the server, to each interested client, and then back to the
13+
server again as an argument to one or more callback functions.
14+
15+
The round-trip of the message is a deliberate design choice, in order to enable the value within the message to be treated
16+
as much as possible like any other piece of data within a ``Dash`` application. This data is essentially stored
17+
on the client side of the client-server split, and passed to the server when each callback is invoked; note that this also
18+
encourages designs that keep the size of in-application data small. An
19+
alternative approach, such as directly invoking
20+
a callback in the server, would require the server to maintain its own copy of the application state.
21+
22+
Live updating requires a server setup that is considerably more
23+
complex than the alternative, namely use of the built-in `Interval <https://dash.plot.ly/live-updates>`_ component. However, live
24+
updating can be used to reduce server load (as callbacks are only made when needed) and application latency (as callbacks are
25+
invoked as needed, not on the tempo of the Interval component).
726

827
Message channels
928
----------------
1029

11-
Blah
30+
Messages are passed through named channels, and each message consists
31+
of a ``label`` and ``value`` pair. A :ref:`Pipe <pipe_component>` component is provided that listens for messages and makes
32+
them available to ``Dash`` callbacks. Each message is sent through a message channel to all ``Pipe`` components that have
33+
registered their interest in that channel, and in turn the components will select messages by ``label``.
34+
35+
A message channel exists as soon as a component signals that it is listening for messages on it. The
36+
message delivery requirement is 'hopefully at least once'. In other words, applications should be robust against both the failure
37+
of a message to be delivered, and also for a message to be delivered multiple times. A design approach that has messages
38+
of the form 'you should look at X and see if something should be done' is strongly encouraged. The accompanying demo has
39+
messages of the form 'button X at time T', for example.
40+
41+
Sending messages from within Django
42+
-----------------------------------
43+
44+
Messages can be easily sent from within Django, provided that they are within the ASGI server.
45+
46+
.. code-block:: python
47+
48+
from django_plotly_dash.consumers import send_to_pipe_channel
49+
50+
# Send a message
51+
#
52+
# This function may return *before* the message has been sent
53+
# to the pipe channel.
54+
#
55+
send_to_pipe_channel(channel_name="live_button_counter",
56+
label="named_counts",
57+
value=value)
58+
59+
# Send a message asynchronously
60+
#
61+
await async_send_to_pipe_channel(channel_name="live_button_counter",
62+
label="named_counts",
63+
value=value)
64+
65+
In general, making assumptions about the ordering of code between message sending and receiving is
66+
unsafe. The ``send_to_pipe`` function uses the Django Channels ``async_to_sync`` wrapper around
67+
a call to ``async_send_to_pipe`` and therefore may return before the asynchronous call is made (perhaps
68+
on a different thread). Furthermore, the transit of the message through the channels backend
69+
introduces another indeterminacy.
70+
71+
HTTP Endpoint
72+
-------------
73+
74+
There is an HTTP endpoint, :ref:`configured <configuration>` with
75+
the ``http_route`` option, that allows direct insertion of messages into a message
76+
channel. It is a
77+
direct equivalent of calling the ``send_to_pipe_channel`` function, and
78+
expects the ``channel_name``, ``label`` and ``value`` arguments to be provided in a JSON-encoded
79+
dictionary.
80+
81+
.. code-block:: bash
82+
83+
curl -d '{"channel_name":"live_button_counter",
84+
"label":"named_counts",
85+
"value":{"click_colour":"cyan"}}'
86+
http://localhost:8000/dpd/views/poke/
87+
88+
This will cause the (JSON-encoded) ``value`` argument to be sent on the ``channel_name`` channel with
89+
the given ``label``.
90+
91+
The provided endpoint skips any CSRF checks
92+
and does not perform any security checks such as authentication or authorisation, and should
93+
be regarded as a starting point for a more complete implementation if exposing this functionality is desired. On the
94+
other hand, if this endpoint is restricted so that it is only available from trusted sources such as the server
95+
itself, it does provide a mechanism for Django code running outside of the ASGI server, such as in a WSGI process or
96+
Celery worker, to push a message out to running applications.
1297

13-
Pipes
14-
-----
98+
The ``http_poke_enabled`` flag controls the availability of the endpoint. If false, then it is not registered at all and
99+
all requests will receive a 404 HTTP error code.
15100

16-
A :ref:`Pipe <pipe_component>` component is provided.
101+
Deployment
102+
----------
17103

104+
The live updating feature needs both Redis, as it is the only supported backend at present for v2.0 and up of
105+
Channels, and Daphne or any other ASGI server for production use. It is also good practise to place the server(s) behind
106+
a reverse proxy such as Nginx; this can then also be configured to serve Django's static files.
18107

108+
A further consideration is the use of a WSGI server, such as Gunicorn, to serve the non-asynchronous subset of the http
109+
routes, albeit at the expense of having to separately manage ASGI and WSGI servers. This can be easily achieved through selective
110+
routing at the reverse proxy level, and is the driver behind the ``ws_route`` configuration option.
19111

112+
In passing, note that the demo also uses Redis as the caching backend for Django.

0 commit comments

Comments
 (0)