From a47bd768eaab1459ceabda0f78af0152d370a0e0 Mon Sep 17 00:00:00 2001 From: Mark Gibbs Date: Mon, 14 May 2018 12:55:42 -0700 Subject: [PATCH 01/13] Refactored app loading and refreshed documentation --- README.md | 17 +------ demo/demo/templates/index.html | 6 +-- django_plotly_dash/dash_wrapper.py | 48 ++++++------------- django_plotly_dash/models.py | 39 ++++----------- .../templatetags/plotly_dash.py | 13 ++++- django_plotly_dash/urls.py | 15 ++++-- django_plotly_dash/views.py | 42 ++++++++++++---- docs/simple_use.rst | 2 +- 8 files changed, 82 insertions(+), 100 deletions(-) diff --git a/README.md b/README.md index edfc9794..bf32256f 100644 --- a/README.md +++ b/README.md @@ -88,21 +88,6 @@ def callback_color(dropdown_value): def callback_size(dropdown_color, dropdown_size): return "The chosen T-shirt is a %s %s one." %(dropdown_size, dropdown_color) - -a2 = DelayedDash("Ex2") -a2.layout = html.Div([ - dcc.RadioItems(id="dropdown-one",options=[{'label':i,'value':j} for i,j in [ - ("O2","Oxygen"),("N2","Nitrogen"),] - ],value="Oxygen"), - html.Div(id="output-one") - ]) - -@a2.callback( - dash.dependencies.Output('output-one','children'), - [dash.dependencies.Input('dropdown-one','value')] - ) -def callback_c(*args,**kwargs): - return "Args are %s and kwargs are %s" %("".join(*args),str(kwargs)) ``` Note that the `DelayedDash` constructor requires a name to be specified. This name is then used to identify the dash app in @@ -111,7 +96,7 @@ templates: ```jinja2 {% load plotly_dash %} -{% plotly_item "SimpleExample" %} +{% plotly_item name="SimpleExample" %} ``` The registration code needs to be in a location diff --git a/demo/demo/templates/index.html b/demo/demo/templates/index.html index 2ad76c99..74f318dc 100644 --- a/demo/demo/templates/index.html +++ b/demo/demo/templates/index.html @@ -5,15 +5,15 @@
Content here - {%plotly_item "simpleexample-1" ratio=0.2 %} + {%plotly_app slug="simpleexample-1" ratio=0.2 %}
Content here - {%plotly_item "SimpleExample"%} + {%plotly_app name="SimpleExample"%}
Content here - {%plotly_item "Ex2"%} + {%plotly_app name="Ex2"%}
diff --git a/django_plotly_dash/dash_wrapper.py b/django_plotly_dash/dash_wrapper.py index 0f140238..3145e13e 100644 --- a/django_plotly_dash/dash_wrapper.py +++ b/django_plotly_dash/dash_wrapper.py @@ -13,43 +13,17 @@ uid_counter = 0 usable_apps = {} -nd_apps = {} def add_usable_app(name, app): global usable_apps usable_apps[name] = app -def add_instance(id, instance): - global nd_apps - nd_apps[id] = instance - -def get_app_by_name(name): +def get_stateless_by_name(name): ''' Locate a registered dash app by name, and return a DelayedDash instance encapsulating the app. ''' - return usable_apps.get(name,None) - -def get_app_instance_by_id(id): - ''' - Locate an instance of a dash app by identifier, or return None if one does not exist - ''' - return nd_apps.get(id,None) - -def clear_app_instance(id): - try: - del nd_apps[id] - except: - pass - -def get_or_form_app(id, name, **kwargs): - ''' - Locate an instance of a dash app by identifier, loading or creating a new instance if needed - ''' - app = get_app_instance_by_id(id) - if app: - return app - dd = get_app_by_name(name) - return dd.form_dash_instance() + # TODO wrap this in raising a 404 if not found + return usable_apps[name] class Holder: def __init__(self): @@ -78,9 +52,20 @@ def __init__(self, name=None, **kwargs): self._expanded_callbacks = False + def as_dash_instance(self): + ''' + Form a dash instance, for stateless use of this app + ''' + return self.form_dash_instance() + def form_dash_instance(self, replacements=None, specific_identifier=None): + if not specific_identifier: + app_pathname = "%s:app-%s"% (app_name, main_view_label) + else: + app_pathname="%s:%s" % (app_name, main_view_label) + rd = NotDash(name_root=self._uid, - app_pathname="%s:%s" % (app_name, main_view_label), + app_pathname=app_pathname, expanded_callbacks = self._expanded_callbacks, replacements = replacements, specific_identifier = specific_identifier) @@ -134,8 +119,6 @@ def __init__(self, name_root, app_pathname=None, replacements = None, specific_i else: self._uid = name_root - add_instance(self._uid, self) - self._flask_app = Flask(self._uid) self._notflask = NotFlask() self._base_pathname = reverse(app_pathname,kwargs={'id':self._uid}) @@ -295,4 +278,3 @@ def dispatch_with_args(self, body, argMap): return self.callback_map[target_id]['callback'](*args,**argMap) - diff --git a/django_plotly_dash/models.py b/django_plotly_dash/models.py index aee3221d..db2a1b5f 100644 --- a/django_plotly_dash/models.py +++ b/django_plotly_dash/models.py @@ -2,7 +2,7 @@ from django.contrib import admin from django.utils.text import slugify -from .dash_wrapper import get_app_instance_by_id, get_app_by_name, clear_app_instance +from .dash_wrapper import get_stateless_by_name import json @@ -27,46 +27,25 @@ def save(self, *args, **kwargs): if not self.slug or len(self.slug) < 2: self.slug = slugify(self.instance_name) super(DashApp, self).save(*args,**kwargs) - # TODO at this point should invaliate any local cache of the older values - clear_app_instance(self.slug) + + def _stateless_dash_app(self): + # TODO make this a property of the object + dd = get_stateless_by_name(self.app_name) + return dd def as_dash_instance(self): - ai = get_app_instance_by_id(self.slug) - if ai: - return ai - dd = get_app_by_name(self.app_name) + dd = self._stateless_dash_app() base = json.loads(self.base_state) return dd.form_dash_instance(replacements=base, specific_identifier=self.slug) - @staticmethod - def get_app_instance(id): - ''' - Locate an application instance by id, either in local cache or in Database. - If in neither, then create a new instance assuming that the id is the app name. - ''' - local_instance = get_app_instance_by_id(id) - if local_instance: - return local_instance - try: - return DashApp.objects.get(slug=id).as_dash_instance() - except: - pass - - # Really no luck at all! - dd = get_app_by_name(id) - return dd.form_dash_instance() - def _get_base_state(self): ''' Get the base state of the object, as defined by the app.layout code, as a python dict ''' - # Get base layout response, from a base object - base_app_inst = get_app_instance_by_id(self.app_name) - if not base_app_inst: - base_app = get_app_by_name(self.app_name) - base_app_inst = base_app.form_dash_instance() + base_app_inst = self._stateless_dash_app() + # Get base layout response, from a base object base_resp = base_app_inst.locate_endpoint_function('dash-layout')() base_obj = json.loads(base_resp.data.decode('utf-8')) diff --git a/django_plotly_dash/templatetags/plotly_dash.py b/django_plotly_dash/templatetags/plotly_dash.py index 567f6e56..edc30f0a 100644 --- a/django_plotly_dash/templatetags/plotly_dash.py +++ b/django_plotly_dash/templatetags/plotly_dash.py @@ -1,11 +1,13 @@ from django import template +from django.shortcuts import get_object_or_404 register = template.Library() from django_plotly_dash.models import DashApp +from django_plotly_dash.dash_wrapper import get_stateless_by_name @register.inclusion_tag("django_plotly_dash/plotly_item.html", takes_context=True) -def plotly_item(context, app_name, ratio=0.1, use_frameborder=False): +def plotly_app(context, name=None, slug=None, ratio=0.1, use_frameborder=False): fbs = use_frameborder and '1' or '0' @@ -24,6 +26,13 @@ def plotly_item(context, app_name, ratio=0.1, use_frameborder=False): height: 100%; """ - app = DashApp.get_app_instance(app_name) + if name is not None: + da = get_stateless_by_name(name) + app = da.form_dash_instance() + + if slug is not None: + da = get_object_or_404(DashApp,slug=slug) + app = da.as_dash_instance() return locals() + diff --git a/django_plotly_dash/urls.py b/django_plotly_dash/urls.py index 1d6696b6..dc81954f 100644 --- a/django_plotly_dash/urls.py +++ b/django_plotly_dash/urls.py @@ -6,11 +6,16 @@ from .app_name import app_name, main_view_label urlpatterns = [ - path('_dash-routes', routes, name="routes"), - path('_dash-layout', layout, name="layout"), - path('_dash-dependencies', dependencies, name="dependencies"), - path('_dash-update-component', csrf_exempt(update), name="update-component"), + path('instance/_dash-routes', routes, name="routes"), + path('instance/_dash-layout', layout, name="layout"), + path('instance/_dash-dependencies', dependencies, name="dependencies"), + path('instance/_dash-update-component', csrf_exempt(update), name="update-component"), + path('instance/', main_view, name=main_view_label), - path('', main_view, name=main_view_label), + path('app/_dash-routes', routes, {'stateless':True}, name="app-routes"), + path('app/_dash-layout', layout, {'stateless':True}, name="app-layout"), + path('app/_dash-dependencies', dependencies, {'stateless':True}, name="app-dependencies"), + path('app/_dash-update-component', csrf_exempt(update), {'stateless':True}, name="app-update-component"), + path('app/', main_view, {'stateless':True}, name='app-%s'%main_view_label), ] diff --git a/django_plotly_dash/views.py b/django_plotly_dash/views.py index 7247992c..bb8db577 100644 --- a/django_plotly_dash/views.py +++ b/django_plotly_dash/views.py @@ -1,29 +1,46 @@ -from django.shortcuts import render +from django.shortcuts import render, get_object_or_404 from django.http import HttpResponse import json from .models import DashApp +from .dash_wrapper import get_stateless_by_name def routes(*args,**kwargs): - pass + raise NotImplementedError + +def dependencies(request, id, stateless=False, **kwargs): + if stateless: + da = get_stateless_by_name(id) + else: + da = get_object_or_404(DashApp,slug=id) + + app = da.as_dash_instance() -def dependencies(request, id, **kwargs): - app = DashApp.get_app_instance(id) with app.app_context(): mFunc = app.locate_endpoint_function('dash-dependencies') resp = mFunc() return HttpResponse(resp.data, content_type=resp.mimetype) -def layout(request, id, **kwargs): - app = DashApp.get_app_instance(id) +def layout(request, id, stateless=False, **kwargs): + if stateless: + da = get_stateless_by_name(id) + else: + da = get_object_or_404(DashApp,slug=id) + app = da.as_dash_instance() + mFunc = app.locate_endpoint_function('dash-layout') resp = mFunc() return app.augment_initial_layout(resp) -def update(request, id, **kwargs): - app = DashApp.get_app_instance(id) +def update(request, id, stateless=False, **kwargs): + if stateless: + da = get_stateless_by_name(id) + else: + da = get_object_or_404(DashApp,slug=id) + app = da.as_dash_instance() + rb = json.loads(request.body.decode('utf-8')) if app.use_dash_dispatch(): @@ -44,8 +61,13 @@ def update(request, id, **kwargs): return HttpResponse(resp.data, content_type=resp.mimetype) -def main_view(request, id, **kwargs): - app = DashApp.get_app_instance(id) +def main_view(request, id, stateless=False, **kwargs): + if stateless: + da = get_stateless_by_name(id) + else: + da = get_object_or_404(DashApp,slug=id) + app = da.as_dash_instance() + mFunc = app.locate_endpoint_function() resp = mFunc() return HttpResponse(resp) diff --git a/docs/simple_use.rst b/docs/simple_use.rst index a7fae581..49bc7d86 100644 --- a/docs/simple_use.rst +++ b/docs/simple_use.rst @@ -52,7 +52,7 @@ templates::: {%load plotly_dash%} - {%plotly_item "SimpleExample"%} + {%plotly_item name="SimpleExample"%} The registration code needs to be in a location that will be imported into the Django process before any model or From d17f822ef9b1cc704f98f9e272dd871319ca1b33 Mon Sep 17 00:00:00 2001 From: Mark Gibbs Date: Mon, 14 May 2018 13:00:46 -0700 Subject: [PATCH 02/13] Neater object location --- django_plotly_dash/models.py | 11 +++++++++++ django_plotly_dash/views.py | 26 ++++---------------------- 2 files changed, 15 insertions(+), 22 deletions(-) diff --git a/django_plotly_dash/models.py b/django_plotly_dash/models.py index db2a1b5f..25e0c974 100644 --- a/django_plotly_dash/models.py +++ b/django_plotly_dash/models.py @@ -1,6 +1,7 @@ from django.db import models from django.contrib import admin from django.utils.text import slugify +from django.shortcuts import get_object_or_404 from .dash_wrapper import get_stateless_by_name @@ -62,6 +63,16 @@ def populate_values(self): obj = self._get_base_state() self.base_state = json.dumps(obj) + @staticmethod + def locate_item(id, stateless=False): + if stateless: + da = get_stateless_by_name(id) + else: + da = get_object_or_404(DashApp,slug=id) + + app = da.as_dash_instance() + return da, app + class DashAppAdmin(admin.ModelAdmin): list_display = ['instance_name','app_name','slug','creation','update',] list_filter = ['app_name','creation','update',] diff --git a/django_plotly_dash/views.py b/django_plotly_dash/views.py index bb8db577..ab7c97af 100644 --- a/django_plotly_dash/views.py +++ b/django_plotly_dash/views.py @@ -4,18 +4,12 @@ import json from .models import DashApp -from .dash_wrapper import get_stateless_by_name def routes(*args,**kwargs): raise NotImplementedError def dependencies(request, id, stateless=False, **kwargs): - if stateless: - da = get_stateless_by_name(id) - else: - da = get_object_or_404(DashApp,slug=id) - - app = da.as_dash_instance() + da, app = DashApp.locate_item(id, stateless) with app.app_context(): mFunc = app.locate_endpoint_function('dash-dependencies') @@ -24,22 +18,14 @@ def dependencies(request, id, stateless=False, **kwargs): content_type=resp.mimetype) def layout(request, id, stateless=False, **kwargs): - if stateless: - da = get_stateless_by_name(id) - else: - da = get_object_or_404(DashApp,slug=id) - app = da.as_dash_instance() + da, app = DashApp.locate_item(id, stateless) mFunc = app.locate_endpoint_function('dash-layout') resp = mFunc() return app.augment_initial_layout(resp) def update(request, id, stateless=False, **kwargs): - if stateless: - da = get_stateless_by_name(id) - else: - da = get_object_or_404(DashApp,slug=id) - app = da.as_dash_instance() + da, app = DashApp.locate_item(id, stateless) rb = json.loads(request.body.decode('utf-8')) @@ -62,11 +48,7 @@ def update(request, id, stateless=False, **kwargs): content_type=resp.mimetype) def main_view(request, id, stateless=False, **kwargs): - if stateless: - da = get_stateless_by_name(id) - else: - da = get_object_or_404(DashApp,slug=id) - app = da.as_dash_instance() + da, app = DashApp.locate_item(id, stateless) mFunc = app.locate_endpoint_function() resp = mFunc() From 9f1e385c6bf4b1c1624d6f59b53f800e1169474e Mon Sep 17 00:00:00 2001 From: Mark Gibbs Date: Mon, 14 May 2018 13:19:33 -0700 Subject: [PATCH 03/13] Expand range of valid input for template tag --- django_plotly_dash/templatetags/plotly_dash.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/django_plotly_dash/templatetags/plotly_dash.py b/django_plotly_dash/templatetags/plotly_dash.py index edc30f0a..72a749ef 100644 --- a/django_plotly_dash/templatetags/plotly_dash.py +++ b/django_plotly_dash/templatetags/plotly_dash.py @@ -7,7 +7,7 @@ from django_plotly_dash.dash_wrapper import get_stateless_by_name @register.inclusion_tag("django_plotly_dash/plotly_item.html", takes_context=True) -def plotly_app(context, name=None, slug=None, ratio=0.1, use_frameborder=False): +def plotly_app(context, name=None, slug=None, da=None, ratio=0.1, use_frameborder=False): fbs = use_frameborder and '1' or '0' @@ -28,11 +28,10 @@ def plotly_app(context, name=None, slug=None, ratio=0.1, use_frameborder=False): if name is not None: da = get_stateless_by_name(name) - app = da.form_dash_instance() if slug is not None: da = get_object_or_404(DashApp,slug=slug) - app = da.as_dash_instance() + app = da.form_dash_instance() return locals() From 43a66d508d8ea58d12243dc6e275a2088786ecf4 Mon Sep 17 00:00:00 2001 From: Mark Gibbs Date: Mon, 14 May 2018 13:34:05 -0700 Subject: [PATCH 04/13] Use slugified form of all app names --- django_plotly_dash/dash_wrapper.py | 4 ++++ django_plotly_dash/models.py | 6 ++++-- django_plotly_dash/templatetags/plotly_dash.py | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/django_plotly_dash/dash_wrapper.py b/django_plotly_dash/dash_wrapper.py index 3145e13e..a204516c 100644 --- a/django_plotly_dash/dash_wrapper.py +++ b/django_plotly_dash/dash_wrapper.py @@ -3,6 +3,7 @@ from django.urls import reverse from django.http import HttpResponse +from django.utils.text import slugify import json @@ -15,13 +16,16 @@ usable_apps = {} def add_usable_app(name, app): + name = slugify(name) global usable_apps usable_apps[name] = app + return name def get_stateless_by_name(name): ''' Locate a registered dash app by name, and return a DelayedDash instance encapsulating the app. ''' + name = slugify(name) # TODO wrap this in raising a 404 if not found return usable_apps[name] diff --git a/django_plotly_dash/models.py b/django_plotly_dash/models.py index 25e0c974..4f972fc2 100644 --- a/django_plotly_dash/models.py +++ b/django_plotly_dash/models.py @@ -30,8 +30,10 @@ def save(self, *args, **kwargs): super(DashApp, self).save(*args,**kwargs) def _stateless_dash_app(self): - # TODO make this a property of the object - dd = get_stateless_by_name(self.app_name) + dd = getattr(self,'_stateless_dash_app_instance',None) + if not dd: + dd = get_stateless_by_name(self.app_name) + setattr(self,'_stateless_dash_app_instance',dd) return dd def as_dash_instance(self): diff --git a/django_plotly_dash/templatetags/plotly_dash.py b/django_plotly_dash/templatetags/plotly_dash.py index 72a749ef..10ba444e 100644 --- a/django_plotly_dash/templatetags/plotly_dash.py +++ b/django_plotly_dash/templatetags/plotly_dash.py @@ -32,6 +32,6 @@ def plotly_app(context, name=None, slug=None, da=None, ratio=0.1, use_frameborde if slug is not None: da = get_object_or_404(DashApp,slug=slug) - app = da.form_dash_instance() + app = da.as_dash_instance() return locals() From f15a3858f7514e03448e44bb6baf13fd0d77c09a Mon Sep 17 00:00:00 2001 From: Mark Gibbs Date: Mon, 14 May 2018 13:47:57 -0700 Subject: [PATCH 05/13] Added session state to main callback --- django_plotly_dash/views.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/django_plotly_dash/views.py b/django_plotly_dash/views.py index ab7c97af..e956f485 100644 --- a/django_plotly_dash/views.py +++ b/django_plotly_dash/views.py @@ -40,9 +40,12 @@ def update(request, id, stateless=False, **kwargs): resp = mFunc() else: # Use direct dispatch with extra arguments in the argMap - argMap = {'id':id, - 'session':request.session} + app_state = request.session.get("django_plotly_dash",dict()) + argMap = {'dash_app_id': id, + 'user': request.user, + 'session_state': app_state} resp = app.dispatch_with_args(rb, argMap) + request.session['django_plotly_dash'] = app_state return HttpResponse(resp.data, content_type=resp.mimetype) From 80163a3a93bd19f4888b6af88137514f09a1c577 Mon Sep 17 00:00:00 2001 From: Mark Gibbs Date: Mon, 14 May 2018 13:54:27 -0700 Subject: [PATCH 06/13] Fix timezones and object extraction when initially populating --- demo/demo/settings.py | 2 +- django_plotly_dash/models.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/demo/demo/settings.py b/demo/demo/settings.py index aeae9699..d26e9373 100644 --- a/demo/demo/settings.py +++ b/demo/demo/settings.py @@ -107,7 +107,7 @@ LANGUAGE_CODE = 'en-us' -TIME_ZONE = 'UTC' +TIME_ZONE = 'America/Vancouver' USE_I18N = True diff --git a/django_plotly_dash/models.py b/django_plotly_dash/models.py index 4f972fc2..3cd6b5d2 100644 --- a/django_plotly_dash/models.py +++ b/django_plotly_dash/models.py @@ -14,7 +14,7 @@ class DashApp(models.Model): app_name = models.CharField(max_length=100, blank=False, null=False, unique=False) instance_name = models.CharField(max_length=100, unique=True, blank=True, null=False) slug = models.SlugField(max_length=110, unique=True, blank=True) - base_state = models.TextField(null=False) # If mandating postgresql then this could be a JSONField + base_state = models.TextField(null=False, default="{}") # If mandating postgresql then this could be a JSONField creation = models.DateTimeField(auto_now_add=True) update = models.DateTimeField(auto_now=True) @@ -46,7 +46,7 @@ def _get_base_state(self): ''' Get the base state of the object, as defined by the app.layout code, as a python dict ''' - base_app_inst = self._stateless_dash_app() + base_app_inst = self._stateless_dash_app().as_dash_instance() # Get base layout response, from a base object base_resp = base_app_inst.locate_endpoint_function('dash-layout')() From 8951bcf4297c97b0992e72e37a11716427068c2e Mon Sep 17 00:00:00 2001 From: Mark Gibbs Date: Mon, 14 May 2018 18:15:52 -0700 Subject: [PATCH 07/13] Added a save-on-change flag to allow the automatic updating of objects in the database. Centralised the calls so caching can be used as well (or instead) if desired --- README.md | 2 +- demo/demo/plotly_apps.py | 1 + django_plotly_dash/dash_wrapper.py | 44 ++++++++++++------ .../migrations/0003_auto_20180514_1802.py | 23 ++++++++++ django_plotly_dash/models.py | 46 +++++++++++++++++-- django_plotly_dash/views.py | 2 + 6 files changed, 101 insertions(+), 17 deletions(-) create mode 100644 django_plotly_dash/migrations/0003_auto_20180514_1802.py diff --git a/README.md b/README.md index bf32256f..19b1d701 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ cd django-plotly-dash ## Usage To use existing dash applications, first register them using the `DelayedDash` class. This -replaces the `dash.Dash` class of `plotly.py.` +replaces the `Dash` class of the `dash` package. Taking a very simple example inspired by the excellent [getting started](https://dash.plot.ly/) documentation: diff --git a/demo/demo/plotly_apps.py b/demo/demo/plotly_apps.py index dee9c85e..7e4e8cdd 100644 --- a/demo/demo/plotly_apps.py +++ b/demo/demo/plotly_apps.py @@ -49,5 +49,6 @@ def callback_size(dropdown_color, dropdown_size): [dash.dependencies.Input('dropdown-one','value')] ) def callback_c(*args,**kwargs): + da = kwargs['dash_app'] return "Args are %s and kwargs are %s" %("".join(*args),str(kwargs)) diff --git a/django_plotly_dash/dash_wrapper.py b/django_plotly_dash/dash_wrapper.py index a204516c..c4fba812 100644 --- a/django_plotly_dash/dash_wrapper.py +++ b/django_plotly_dash/dash_wrapper.py @@ -62,6 +62,16 @@ def as_dash_instance(self): ''' return self.form_dash_instance() + def handle_current_state(self): + 'Do nothing impl - only matters if state present' + pass + def update_current_state(self, wid, key, value): + 'Do nothing impl - only matters if state present' + pass + def have_current_state_entry(self, wid, key): + 'Do nothing impl - only matters if state present' + pass + def form_dash_instance(self, replacements=None, specific_identifier=None): if not specific_identifier: app_pathname = "%s:app-%s"% (app_name, main_view_label) @@ -266,19 +276,27 @@ def dispatch_with_args(self, body, argMap): target_id = '{}.{}'.format(output['id'], output['property']) args = [] - for component_registration in self.callback_map[target_id]['inputs']: - args.append([ - c.get('value', None) for c in inputs if - c['property'] == component_registration['property'] and - c['id'] == component_registration['id'] - ][0]) - for component_registration in self.callback_map[target_id]['state']: - args.append([ - c.get('value', None) for c in state if - c['property'] == component_registration['property'] and - c['id'] == component_registration['id'] - ][0]) + da = argMap.get('dash_app', None) - return self.callback_map[target_id]['callback'](*args,**argMap) + for component_registration in self.callback_map[target_id]['inputs']: + for c in inputs: + if c['property'] == component_registration['property'] and c['id'] == component_registration['id']: + v = c.get('value',None) + args.append(v) + if da: da.update_current_state(c['id'],c['property'],v) + for component_registration in self.callback_map[target_id]['state']: + for c in state: + if c['property'] == component_registration['property'] and c['id'] == component_registration['id']: + v = c.get('value',None) + args.append(v) + if da: da.update_current_state(c['id'],c['property'],v) + + res = self.callback_map[target_id]['callback'](*args,**argMap) + if da and da.have_current_state_entry(output['id'], output['property']): + response = json.loads(res.data.decode('utf-8')) + value = response.get('response',{}).get('props',{}).get(output['property'],None) + da.update_current_state(output['id'], output['property'], value) + + return res diff --git a/django_plotly_dash/migrations/0003_auto_20180514_1802.py b/django_plotly_dash/migrations/0003_auto_20180514_1802.py new file mode 100644 index 00000000..b383c0be --- /dev/null +++ b/django_plotly_dash/migrations/0003_auto_20180514_1802.py @@ -0,0 +1,23 @@ +# Generated by Django 2.0.5 on 2018-05-15 01:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_plotly_dash', '0002_simple_example_state'), + ] + + operations = [ + migrations.AddField( + model_name='dashapp', + name='save_on_change', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='dashapp', + name='base_state', + field=models.TextField(default='{}'), + ), + ] diff --git a/django_plotly_dash/models.py b/django_plotly_dash/models.py index 3cd6b5d2..e0a8a629 100644 --- a/django_plotly_dash/models.py +++ b/django_plotly_dash/models.py @@ -17,6 +17,7 @@ class DashApp(models.Model): base_state = models.TextField(null=False, default="{}") # If mandating postgresql then this could be a JSONField creation = models.DateTimeField(auto_now_add=True) update = models.DateTimeField(auto_now=True) + save_on_change = models.BooleanField(null=False,default=False) def __str__(self): return self.instance_name @@ -36,9 +37,48 @@ def _stateless_dash_app(self): setattr(self,'_stateless_dash_app_instance',dd) return dd + def handle_current_state(self): + ''' + Check to see if the current hydrated state and the saved state are different. + + If they are, then persist the current state in the database by saving the model instance. + ''' + if getattr(self,'_current_state_hydrated_changed',False) and self.save_on_change: + new_base_state = json.dumps(getattr(self,'_current_state_hydrated',{})) + if new_base_state != self.base_state: + self.base_state = new_base_state + self.save() + + def have_current_state_entry(self, wid, key): + cscoll = self.current_state() + cs = cscoll.get(wid,{}) + return key in cs + + def update_current_state(self, wid, key, value): + ''' + Update current state with a (possibly new) value associated with key + + If the key does not represent an existing entry, then ignore it + ''' + cscoll = self.current_state() + cs = cscoll.get(wid,{}) + if key in cs: + current_value = cs.get(key,None) + if current_value != value: + cs[key] = value + setattr(self,'_current_state_hydrated_changed',True) + + def current_state(self): + cs = getattr(self,'_current_state_hydrated',None) + if not cs: + cs = json.loads(self.base_state) + setattr(self,'_current_state_hydrated',cs) + setattr(self,'_current_state_hydrated_changed',False) + return cs + def as_dash_instance(self): dd = self._stateless_dash_app() - base = json.loads(self.base_state) + base = self.current_state() return dd.form_dash_instance(replacements=base, specific_identifier=self.slug) @@ -76,8 +116,8 @@ def locate_item(id, stateless=False): return da, app class DashAppAdmin(admin.ModelAdmin): - list_display = ['instance_name','app_name','slug','creation','update',] - list_filter = ['app_name','creation','update',] + list_display = ['instance_name','app_name','slug','creation','update','save_on_change',] + list_filter = ['creation','update','save_on_change','app_name',] def _populate_values(self, request, queryset): for da in queryset: diff --git a/django_plotly_dash/views.py b/django_plotly_dash/views.py index e956f485..abd73ebb 100644 --- a/django_plotly_dash/views.py +++ b/django_plotly_dash/views.py @@ -42,10 +42,12 @@ def update(request, id, stateless=False, **kwargs): # Use direct dispatch with extra arguments in the argMap app_state = request.session.get("django_plotly_dash",dict()) argMap = {'dash_app_id': id, + 'dash_app': da, 'user': request.user, 'session_state': app_state} resp = app.dispatch_with_args(rb, argMap) request.session['django_plotly_dash'] = app_state + da.handle_current_state() return HttpResponse(resp.data, content_type=resp.mimetype) From 69d9b897d37349a2dbc4e4727d80d18394938c76 Mon Sep 17 00:00:00 2001 From: Mark Gibbs Date: Mon, 14 May 2018 21:59:06 -0700 Subject: [PATCH 08/13] Refactored DelayedDash into DjangoDash class. Extended documentation scope --- README.md | 8 ++++---- demo/demo/plotly_apps.py | 6 +++--- django_plotly_dash/__init__.py | 2 +- django_plotly_dash/dash_wrapper.py | 4 ++-- docs/extended_callbacks.rst | 6 ++++++ docs/index.rst | 3 +++ docs/models_and_state.rst | 4 ++++ docs/simple_use.rst | 12 ++++++------ docs/template_tags.rst | 5 +++++ 9 files changed, 34 insertions(+), 16 deletions(-) create mode 100644 docs/extended_callbacks.rst create mode 100644 docs/models_and_state.rst create mode 100644 docs/template_tags.rst diff --git a/README.md b/README.md index 19b1d701..ee8b2deb 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ cd django-plotly-dash ## Usage -To use existing dash applications, first register them using the `DelayedDash` class. This +To use existing dash applications, first register them using the `DjangoDash` class. This replaces the `Dash` class of the `dash` package. Taking a very simple example inspired by the excellent [getting started](https://dash.plot.ly/) documentation: @@ -55,9 +55,9 @@ import dash import dash_core_components as dcc import dash_html_components as html -from django_plotly_dash import DelayedDash +from django_plotly_dash import DjangoDash -app = DelayedDash('SimpleExample') +app = DjangoDash('SimpleExample') app.layout = html.Div([ dcc.RadioItems( @@ -90,7 +90,7 @@ def callback_size(dropdown_color, dropdown_size): dropdown_color) ``` -Note that the `DelayedDash` constructor requires a name to be specified. This name is then used to identify the dash app in +Note that the `DjangoDash` constructor requires a name to be specified. This name is then used to identify the dash app in templates: ```jinja2 diff --git a/demo/demo/plotly_apps.py b/demo/demo/plotly_apps.py index 7e4e8cdd..0a97c87c 100644 --- a/demo/demo/plotly_apps.py +++ b/demo/demo/plotly_apps.py @@ -2,9 +2,9 @@ import dash_core_components as dcc import dash_html_components as html -from django_plotly_dash import DelayedDash +from django_plotly_dash import DjangoDash -app = DelayedDash('SimpleExample') +app = DjangoDash('SimpleExample') app.layout = html.Div([ dcc.RadioItems( @@ -36,7 +36,7 @@ def callback_size(dropdown_color, dropdown_size): return "The chosen T-shirt is a %s %s one." %(dropdown_size, dropdown_color) -a2 = DelayedDash("Ex2") +a2 = DjangoDash("Ex2") a2.layout = html.Div([ dcc.RadioItems(id="dropdown-one",options=[{'label':i,'value':j} for i,j in [ ("O2","Oxygen"),("N2","Nitrogen"),] diff --git a/django_plotly_dash/__init__.py b/django_plotly_dash/__init__.py index 21100bbf..891be82a 100644 --- a/django_plotly_dash/__init__.py +++ b/django_plotly_dash/__init__.py @@ -2,5 +2,5 @@ __version__ = "0.0.4" -from .dash_wrapper import DelayedDash +from .dash_wrapper import DjangoDash diff --git a/django_plotly_dash/dash_wrapper.py b/django_plotly_dash/dash_wrapper.py index c4fba812..e3572df9 100644 --- a/django_plotly_dash/dash_wrapper.py +++ b/django_plotly_dash/dash_wrapper.py @@ -23,7 +23,7 @@ def add_usable_app(name, app): def get_stateless_by_name(name): ''' - Locate a registered dash app by name, and return a DelayedDash instance encapsulating the app. + Locate a registered dash app by name, and return a DjangoDash instance encapsulating the app. ''' name = slugify(name) # TODO wrap this in raising a 404 if not found @@ -37,7 +37,7 @@ def append_css(self, stylesheet): def append_script(self, script): self.items.append(script) -class DelayedDash: +class DjangoDash: def __init__(self, name=None, **kwargs): if name is None: global uid_counter diff --git a/docs/extended_callbacks.rst b/docs/extended_callbacks.rst new file mode 100644 index 00000000..39b2277e --- /dev/null +++ b/docs/extended_callbacks.rst @@ -0,0 +1,6 @@ +.. _extended_callbacks: + +Extended callback syntax +======================== + +The ``DjangoDash`` class allows callbacks to request extra arguments when registered. diff --git a/docs/index.rst b/docs/index.rst index 06d66d94..ce6f4727 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -17,6 +17,9 @@ Contents introduction installation simple_use + extended_callbacks + template_tags + models_and_state Indices and tables diff --git a/docs/models_and_state.rst b/docs/models_and_state.rst new file mode 100644 index 00000000..c408a554 --- /dev/null +++ b/docs/models_and_state.rst @@ -0,0 +1,4 @@ +.. _models_and_state: + +Django models and application state +================ diff --git a/docs/simple_use.rst b/docs/simple_use.rst index 49bc7d86..a79c8692 100644 --- a/docs/simple_use.rst +++ b/docs/simple_use.rst @@ -1,9 +1,9 @@ .. _simple_use: -Simple Usage +Simple usage ============ -To use existing dash applications, first register them using the ``DelayedDash`` class. This +To use existing dash applications, first register them using the ``DjangoDash`` class. This replaces the ``Dash`` class from the ``dash`` package. Taking a simple example inspired by the excellent `getting started `_ guide:: @@ -12,9 +12,9 @@ Taking a simple example inspired by the excellent `getting started Date: Tue, 15 May 2018 11:20:58 -0700 Subject: [PATCH 09/13] Documentation on extended callbacks and an overview of how things work --- README.md | 6 ++++- demo/demo/plotly_apps.py | 4 ++-- docs/extended_callbacks.rst | 44 +++++++++++++++++++++++++++++++++++++ docs/introduction.rst | 18 ++++++++++++++- 4 files changed, 68 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ee8b2deb..245ac841 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # django-plotly-dash -Expose [plotly dash](https://plot.ly/products/dash/) apps as django tags. +Expose [plotly dash](https://plot.ly/products/dash/) apps as [Django](https:://www.djangoproject.com/) tags. Multiple Dash apps can +then be embedded into a single web page, persist and share internal state, and also have access to the +current user and session variables. See the source for this project here: @@ -8,6 +10,8 @@ See the source for this project here: This README file provides a short guide to installing and using the package, and also outlines how to run the demonstration application. + + More detailed information can be found in the online documentation at diff --git a/demo/demo/plotly_apps.py b/demo/demo/plotly_apps.py index 0a97c87c..a6bb63d8 100644 --- a/demo/demo/plotly_apps.py +++ b/demo/demo/plotly_apps.py @@ -39,7 +39,7 @@ def callback_size(dropdown_color, dropdown_size): a2 = DjangoDash("Ex2") a2.layout = html.Div([ dcc.RadioItems(id="dropdown-one",options=[{'label':i,'value':j} for i,j in [ - ("O2","Oxygen"),("N2","Nitrogen"),] + ("O2","Oxygen"),("N2","Nitrogen"),("CO2","Carbon Dioxide")] ],value="Oxygen"), html.Div(id="output-one") ]) @@ -50,5 +50,5 @@ def callback_size(dropdown_color, dropdown_size): ) def callback_c(*args,**kwargs): da = kwargs['dash_app'] - return "Args are %s and kwargs are %s" %("".join(*args),str(kwargs)) + return "Args are [%s] and kwargs are %s" %(",".join(args),str(kwargs)) diff --git a/docs/extended_callbacks.rst b/docs/extended_callbacks.rst index 39b2277e..2c027b43 100644 --- a/docs/extended_callbacks.rst +++ b/docs/extended_callbacks.rst @@ -4,3 +4,47 @@ Extended callback syntax ======================== The ``DjangoDash`` class allows callbacks to request extra arguments when registered. + +To do this, simply replace ``callback`` with ``expanded_callback`` when registering any callback. This will cause all of the callbacks +registered with this application +to receive extra ``kwargs`` in addition to the callback parameters. + +For example, the ``plotly_apps.py`` example contains this dash application:: + + a2 = DjangoDash("Ex2") + + a2.layout = html.Div([ + dcc.RadioItems(id="dropdown-one",options=[{'label':i,'value':j} for i,j in [ + ("O2","Oxygen"),("N2","Nitrogen"),("CO2","Carbon Dioxide")] + ],value="Oxygen"), + html.Div(id="output-one") + ]) + + @a2.expanded_callback( + dash.dependencies.Output('output-one','children'), + [dash.dependencies.Input('dropdown-one','value')] + ) + + def callback_c(*args,**kwargs): + da = kwargs['dash_app'] + return "Args are [%s] and kwargs are %s" %(",".join(args),str(kwargs)) + +The additional arguments, which are reported as the ``kwargs`` content in this example, include + +:dash_app: For stateful applications, the ``DashApp`` model instance +:dash_app_id: The application identifier. For stateless applications, this is the (slugified) name given to the ``DjangoDash`` constructor. + For stateful applications, it is the (slugified) unique identifier for the associated model instance. +:session_state: A dictionary of information, unique to this user session. Any changes made to its content during the + callback are persisted as part of the Django session framework. +:user: The Django User instance. + +The ``DashApp`` model instance can also be configured to persist itself on any change. This is discussed +in the :ref:`models_and_state` section. + + +.. _using_session_state: +Using session state +------------------ + +Changes to the session state and other server-side objects are not automatically propagated to an application. Something in the front-end UI has to invoke a callaback; at this point the latest version of these objects will be provided to the callback. The same considerations as in other Dash `live updates `_ apply. + diff --git a/docs/introduction.rst b/docs/introduction.rst index 9ade7119..d9354f46 100644 --- a/docs/introduction.rst +++ b/docs/introduction.rst @@ -3,7 +3,8 @@ Introduction ============ -The purpose of django-plotly-dash is to enable Plotly Dash applications to be served up as part of a Django application, in order to provide +The purpose of ``django-plotly-dash`` is to enable `Plotly Dash `_ applications +to be served up as part of a `Django `_ application, in order to provide these features: * Multiple dash applications can be used on a single page @@ -13,3 +14,18 @@ these features: There is nothing here that cannot be achieved through expanding the Flask app around Plotly Dash, or indeed by using an alternative web framework. The purpose of this project is to enable the above features, given that the choice to use Django has already been made. + +.. _overview: +Overview +-------- + +``django_plotly_dash`` works by wrapping around the ``dash.Dash`` object. The http endpoints exposed by the +``Dash`` application are mapped to Django ones, and an application is embedded into a webpage through the +use of a template tag. Multiple ``Dash`` applications can be used in a single page. + +A subset of the internal state of a ``Dash`` application can be persisted as a standard Django model instance, and the application with this +internal state is then available at its own URL. This can then be embedded into one or more pages in the same manner as described +above for stateless applications. + +Also, an enhanced version of the ``Dash`` callback is provided, giving the callback access to the current User, the current session, and also +the model instance associated with the application's internal state. From 852e01aa0fd61338cc26e54043493d1d27660d28 Mon Sep 17 00:00:00 2001 From: Mark Gibbs Date: Tue, 15 May 2018 11:31:03 -0700 Subject: [PATCH 10/13] Brief documentation on the template tag --- docs/template_tags.rst | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/template_tags.rst b/docs/template_tags.rst index 39a96452..095d828f 100644 --- a/docs/template_tags.rst +++ b/docs/template_tags.rst @@ -3,3 +3,17 @@ Template tags ============= +The ``plotly_dash`` library provides the ``plotly_app`` template tag. This tag inserts +a ``Dash`` app within a page as a responsive ``iframe`` element. + +:name = None: The name of the application, as passed to a ``DjangoDash`` constructor. +:slug = None: The slug of the application instance. +:da = None: An existing ``django_plotly_dash.models.DashApp`` model instance. +:ratio = 0.1: The ratio of height to width. The container will inherit its width as 100% of its parent, and then rely on + this ratio to set its height. +:use_frameborder = "0": HTML element property of the iframe containing the application. + +At least one of ``da``, ``slug`` and ``name`` must be provided. An object identified by ``slug`` will always be used, otherwise any +identified by ``name`` will be. If either of these arguments are provided, they must resolve to valid objects even if +not used. If neither are provided, then the model instance in ``da`` will be used. + From 2e81c522f6b621fd8129a2cd5d9e437dde82d991 Mon Sep 17 00:00:00 2001 From: Mark Gibbs Date: Tue, 15 May 2018 11:33:56 -0700 Subject: [PATCH 11/13] Add example use of template tag --- docs/template_tags.rst | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/template_tags.rst b/docs/template_tags.rst index 095d828f..6edaf789 100644 --- a/docs/template_tags.rst +++ b/docs/template_tags.rst @@ -3,8 +3,16 @@ Template tags ============= -The ``plotly_dash`` library provides the ``plotly_app`` template tag. This tag inserts -a ``Dash`` app within a page as a responsive ``iframe`` element. +Importing the ``plotly_dash`` library provides the ``plotly_app`` template tag:: + + {%load plotly_dash%} + + {%plotly_item name="SimpleExample"%} + +This tag inserts +a ``DjangoDash`` app within a page as a responsive ``iframe`` element. + +The tag arguments are: :name = None: The name of the application, as passed to a ``DjangoDash`` constructor. :slug = None: The slug of the application instance. @@ -13,7 +21,7 @@ a ``Dash`` app within a page as a responsive ``iframe`` element. this ratio to set its height. :use_frameborder = "0": HTML element property of the iframe containing the application. -At least one of ``da``, ``slug`` and ``name`` must be provided. An object identified by ``slug`` will always be used, otherwise any +At least one of ``da``, ``slug`` or ``name`` must be provided. An object identified by ``slug`` will always be used, otherwise any identified by ``name`` will be. If either of these arguments are provided, they must resolve to valid objects even if not used. If neither are provided, then the model instance in ``da`` will be used. From f86addb6cb768c1ee5c0f18b1ee8973dd545e9d6 Mon Sep 17 00:00:00 2001 From: Mark Gibbs Date: Tue, 15 May 2018 12:00:15 -0700 Subject: [PATCH 12/13] Added model documentation --- django_plotly_dash/models.py | 5 ++++ docs/extended_callbacks.rst | 10 +++++++- docs/models_and_state.rst | 49 ++++++++++++++++++++++++++++++++++++ docs/simple_use.rst | 4 ++- 4 files changed, 66 insertions(+), 2 deletions(-) diff --git a/django_plotly_dash/models.py b/django_plotly_dash/models.py index e0a8a629..dd6403ae 100644 --- a/django_plotly_dash/models.py +++ b/django_plotly_dash/models.py @@ -69,6 +69,11 @@ def update_current_state(self, wid, key, value): setattr(self,'_current_state_hydrated_changed',True) def current_state(self): + ''' + Return the current internal state of the model instance. + + This is not necessarily the same as the persisted state stored in the self.base_state variable. + ''' cs = getattr(self,'_current_state_hydrated',None) if not cs: cs = json.loads(self.base_state) diff --git a/docs/extended_callbacks.rst b/docs/extended_callbacks.rst index 2c027b43..cff71624 100644 --- a/docs/extended_callbacks.rst +++ b/docs/extended_callbacks.rst @@ -9,7 +9,15 @@ To do this, simply replace ``callback`` with ``expanded_callback`` when register registered with this application to receive extra ``kwargs`` in addition to the callback parameters. -For example, the ``plotly_apps.py`` example contains this dash application:: +For example, the ``plotly_apps.py`` example contains this dash application: + +.. code-block:: python + + import dash + import dash_core_components as dcc + import dash_html_components as html + + from django_plotly_dash import DjangoDash a2 = DjangoDash("Ex2") diff --git a/docs/models_and_state.rst b/docs/models_and_state.rst index c408a554..78474ef1 100644 --- a/docs/models_and_state.rst +++ b/docs/models_and_state.rst @@ -2,3 +2,52 @@ Django models and application state ================ + +The ``django_plotly_dash`` application defines a ``DashApp`` model. This represents an instance of application state. + +.. code-block:: python + + class DashApp(models.Model): + ''' + An instance of this model represents a Dash application and its internal state + ''' + app_name = models.CharField(max_length=100, blank=False, null=False, unique=False) + instance_name = models.CharField(max_length=100, unique=True, blank=True, null=False) + slug = models.SlugField(max_length=110, unique=True, blank=True) + base_state = models.TextField(null=False, default="{}") + creation = models.DateTimeField(auto_now_add=True) + update = models.DateTimeField(auto_now=True) + save_on_change = models.BooleanField(null=False,default=False) + + ... methods, mainly for managing the Dash application state ... + + def current_state(self): + ''' + Return the current internal state of the model instance + ''' + + def update_current_state(self, wid, key, value): + ''' + Update the current internal state, ignorning non-tracked objects + ''' + + def populate_values(self): + ''' + Add values from the underlying dash layout configuration + ''' + +The ``app_name`` corresponds to an application registered through the instantiation of a ``DjangoDash`` object. The ``slug`` field provides a unique identifier +that is used in URLs to identify the instance of an application, and also its associated server-side state. + +The persisted state of the instance is contained, serialised as JSON, in the ``base_state`` variable. This is an arbitrary subset of the internal state of the +object. Whenever a ``Dash`` application requests its state (through the ``_dash-layout`` url), any values from the underlying application that are present in +``base_state`` are overwritten with the persisted values. + +The ``populate_values`` member function can be used to insert all possible initial values into ``base_state``. This functionality is also exposed in the Django +admin for these model instances, as a ``Populate app`` action. + +From callback code, the ``update_current_state`` method can be called to change the initial value of any variable tracked within the ``base_state``. Variables not tracked +will be ignored. This function is automatically called for any callback argument and return value. + +Finally, after any callback has finished, and after any result stored through ``update_current_state``, then the application model instance will be persisted by means +of a call to its ``save`` method, if any changes have been detected and the ``save_on_change`` flag is ``True``. diff --git a/docs/simple_use.rst b/docs/simple_use.rst index a79c8692..c0175c7e 100644 --- a/docs/simple_use.rst +++ b/docs/simple_use.rst @@ -6,7 +6,9 @@ Simple usage To use existing dash applications, first register them using the ``DjangoDash`` class. This replaces the ``Dash`` class from the ``dash`` package. -Taking a simple example inspired by the excellent `getting started `_ guide:: +Taking a simple example inspired by the excellent `getting started `_ guide: + +.. code-block:: python import dash import dash_core_components as dcc From 29cc812741d8bd1ae24d474bd65f86340766c332 Mon Sep 17 00:00:00 2001 From: Mark Gibbs Date: Tue, 15 May 2018 12:00:51 -0700 Subject: [PATCH 13/13] Initial release of v0.1 --- django_plotly_dash/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django_plotly_dash/__init__.py b/django_plotly_dash/__init__.py index 891be82a..86cba23d 100644 --- a/django_plotly_dash/__init__.py +++ b/django_plotly_dash/__init__.py @@ -1,6 +1,6 @@ # -__version__ = "0.0.4" +__version__ = "0.1.0" from .dash_wrapper import DjangoDash