From 5a599768335a602eec52b0b156581f4673984960 Mon Sep 17 00:00:00 2001 From: Mark Gibbs Date: Wed, 9 May 2018 14:30:58 -0700 Subject: [PATCH 01/12] Refactor code and add documentation --- README.md | 7 ++- dev_requirements.txt | 1 - django_plotly_dash/app_name.py | 1 + django_plotly_dash/dash_wrapper.py | 61 ++++++++++++++----- .../django_plotly_dash/plotly_item.html | 2 +- .../templatetags/plotly_dash.py | 5 +- django_plotly_dash/urls.py | 2 +- django_plotly_dash/views.py | 4 +- docs/index.rst | 7 ++- docs/introduction.rst | 15 +++++ docs/simple_use.rst | 6 +- make_env | 2 + prepare_demo | 1 - 13 files changed, 84 insertions(+), 30 deletions(-) create mode 100644 django_plotly_dash/app_name.py create mode 100644 docs/introduction.rst diff --git a/README.md b/README.md index fedca74d..6286b561 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,14 @@ Expose [plotly dash](https://plot.ly/products/dash/) apps as django tags. See the source for this project here: -Online documentation can be found 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 + ## Installation First, install the package. This will also install plotly and some dash packages if they are not already present. diff --git a/dev_requirements.txt b/dev_requirements.txt index a3b7d1d1..5b220038 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -10,7 +10,6 @@ dash-html-components==0.10.1 dash-renderer==0.12.1 decorator==4.3.0 Django==2.0.5 --e git+https://github.com/delsim/django-plotly-dash.git@293b454ee8965422155a971d9e417878dc89ce82#egg=django_plotly_dash docopt==0.6.2 docutils==0.14 Flask==1.0.2 diff --git a/django_plotly_dash/app_name.py b/django_plotly_dash/app_name.py new file mode 100644 index 00000000..9895108d --- /dev/null +++ b/django_plotly_dash/app_name.py @@ -0,0 +1 @@ +app_name = "the_django_plotly_dash" diff --git a/django_plotly_dash/dash_wrapper.py b/django_plotly_dash/dash_wrapper.py index b03709b7..792b1622 100644 --- a/django_plotly_dash/dash_wrapper.py +++ b/django_plotly_dash/dash_wrapper.py @@ -3,6 +3,8 @@ from django.urls import reverse +from .app_name import app_name + uid_counter = 0 usable_apps = {} @@ -10,11 +12,35 @@ nd_apps = {} def get_app_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 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() + +class Holder: + def __init__(self): + self.items = [] + def append_css(self, stylesheet): + self.items.append(stylesheet) + def append_script(self, script): + self.items.append(script) + class DelayedDash: def __init__(self, name=None, **kwargs): if name is None: @@ -24,27 +50,27 @@ def __init__(self, name=None, **kwargs): else: self._uid = name self.layout = None - self._rep_dash = None self._callback_sets = [] + self.css = Holder() + self.scripts = Holder() + global usable_apps usable_apps[self._uid] = self - def _RepDash(self): - if self._rep_dash is None: - self._rep_dash = self._form_repdash() - return self._rep_dash - - def _form_repdash(self): + def form_dash_instance(self): rd = NotDash(name_root=self._uid, - app_pathname="django_plotly_dash:main") + app_pathname="%s:main" % app_name) rd.layout = self.layout + for cb, func in self._callback_sets: rd.callback(**cb)(func) - return rd + for s in self.css.items: + rd.css.append_css(s) + for s in self.scripts.items: + rd.scripts.append_script(s) - def base_url(self): - return self._RepDash().base_url() + return rd def callback(self, output, inputs=[], state=[], events=[]): callback_set = {'output':output, @@ -92,12 +118,12 @@ def __init__(self, name_root, app_pathname, **kwargs): kwargs['url_base_pathname'] = self._base_pathname kwargs['server'] = self._notflask + super(NotDash, self).__init__(**kwargs) global nd_apps nd_apps[self._uid] = self - if False: # True for some debug info and a load of errors... - self.css.config.serve_locally = True - self.scripts.config.serve_locally = True + + self._adjust_id = False def flask_app(self): return self._flask_app @@ -123,10 +149,13 @@ def locate_endpoint_function(self, name=None): @Dash.layout.setter def layout(self, value): - self._fix_component_id(value) + + if self._adjust_id: + self._fix_component_id(value) return Dash.layout.fset(self, value) def _fix_component_id(self, component): + theID = getattr(component,"id",None) if theID is not None: setattr(component,"id",self._fix_id(theID)) @@ -137,6 +166,8 @@ def _fix_component_id(self, component): pass def _fix_id(self, name): + if not self._adjust_id: + return name return "%s_-_%s" %(self._uid, name) diff --git a/django_plotly_dash/templates/django_plotly_dash/plotly_item.html b/django_plotly_dash/templates/django_plotly_dash/plotly_item.html index 23d49438..2568dd75 100644 --- a/django_plotly_dash/templates/django_plotly_dash/plotly_item.html +++ b/django_plotly_dash/templates/django_plotly_dash/plotly_item.html @@ -1,3 +1,3 @@
- +
diff --git a/django_plotly_dash/templatetags/plotly_dash.py b/django_plotly_dash/templatetags/plotly_dash.py index 52fce6e5..935931d2 100644 --- a/django_plotly_dash/templatetags/plotly_dash.py +++ b/django_plotly_dash/templatetags/plotly_dash.py @@ -2,12 +2,11 @@ register = template.Library() -from django_plotly_dash.dash_wrapper import get_app_by_name +from django_plotly_dash.dash_wrapper import get_or_form_app @register.inclusion_tag("django_plotly_dash/plotly_item.html", takes_context=True) def plotly_item(context, app_name): - app = get_app_by_name(app_name) - url = app.base_url() + app = get_or_form_app(app_name, app_name) return locals() diff --git a/django_plotly_dash/urls.py b/django_plotly_dash/urls.py index 375cbbca..64a7ee53 100644 --- a/django_plotly_dash/urls.py +++ b/django_plotly_dash/urls.py @@ -3,7 +3,7 @@ from .views import routes, layout, dependencies, update, main_view -app_name = "django_plotly_dash" +from .app_name import app_name urlpatterns = [ path('_dash-routes', routes, name="routes"), diff --git a/django_plotly_dash/views.py b/django_plotly_dash/views.py index 92dc0a95..9610523e 100644 --- a/django_plotly_dash/views.py +++ b/django_plotly_dash/views.py @@ -3,7 +3,7 @@ import flask import json -from .dash_wrapper import get_app_instance_by_id +from .dash_wrapper import get_app_instance_by_id, get_or_form_app from django.http import HttpResponse def routes(*args,**kwargs): @@ -37,7 +37,7 @@ def update(request, id, **kwargs): content_type=resp.mimetype) def main_view(request, id, **kwargs): - app = get_app_instance_by_id(id) + app = get_or_form_app(id, id) mFunc = app.locate_endpoint_function() resp = mFunc() return HttpResponse(resp) diff --git a/docs/index.rst b/docs/index.rst index 0a169a27..06d66d94 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -3,8 +3,10 @@ You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. -Welcome to django-plotly-dash's documentation! -============================================== +django-plotly-dash +================== + +`Plotly Dash `_ applications served up in Django templates using tags. Contents -------- @@ -12,6 +14,7 @@ Contents .. toctree:: :maxdepth: 2 + introduction installation simple_use diff --git a/docs/introduction.rst b/docs/introduction.rst new file mode 100644 index 00000000..9ade7119 --- /dev/null +++ b/docs/introduction.rst @@ -0,0 +1,15 @@ +.. _introduction: + +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 +these features: + +* Multiple dash applications can be used on a single page +* Separate instances of a dash application can persist along with internal state +* Leverage user management and access control and other parts of the Django infrastructure +* Consolidate into a single server process to simplify scaling + +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. diff --git a/docs/simple_use.rst b/docs/simple_use.rst index d0d39825..dad3d554 100644 --- a/docs/simple_use.rst +++ b/docs/simple_use.rst @@ -4,9 +4,9 @@ Simple 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 `dash`` -Taking as an example a slightly modified variant of one of the `getting started `_ examples:: +Taking a simple example inspired by the excellent `getting started `_ guide:: import dash import dash_core_components as dcc @@ -54,7 +54,7 @@ templates::: {%plotly_item "SimpleExample"%} -Note that the registration code needs to be in a location +The registration code needs to be in a location that will be imported into the Django process before any template tag attempts to use it. The example Django application in the demo subdirectory achieves this through an import in the main urls.py file; any views.py would also be sufficient. diff --git a/make_env b/make_env index 82d9946e..85b5112a 100755 --- a/make_env +++ b/make_env @@ -3,3 +3,5 @@ virtualenv -p python3 env source env/bin/activate pip install -r requirements.txt +pip install -r dev_requirements.txt +python setup.py develop diff --git a/prepare_demo b/prepare_demo index 4021cd1c..55c17b77 100755 --- a/prepare_demo +++ b/prepare_demo @@ -1,7 +1,6 @@ #!/usr/bin/env bash # source env/bin/activate -python setup.py develop cd demo ./manage.py migrate ./manage.py runserver From 8332282e68224352209fb9b4323c97738ce9f526 Mon Sep 17 00:00:00 2001 From: Mark Gibbs Date: Wed, 9 May 2018 15:59:43 -0700 Subject: [PATCH 02/12] Refactoring dispatch for callback enhancements --- django_plotly_dash/dash_wrapper.py | 34 ++++++++++++++++++++++++++++++ django_plotly_dash/views.py | 22 +++++++++++++------ 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/django_plotly_dash/dash_wrapper.py b/django_plotly_dash/dash_wrapper.py index 792b1622..a69569a9 100644 --- a/django_plotly_dash/dash_wrapper.py +++ b/django_plotly_dash/dash_wrapper.py @@ -124,6 +124,11 @@ def __init__(self, name_root, app_pathname, **kwargs): nd_apps[self._uid] = self self._adjust_id = False + self._dash_dispatch = False + + def use_dash_dispatch(self): + # TODO make this be a function of using kwargs in callbacks + return self._dash_dispatch def flask_app(self): return self._flask_app @@ -181,3 +186,32 @@ def callback(self, output, inputs=[], state=[], events=[]): [self._fix_callback_item(x) for x in state], [self._fix_callback_item(x) for x in events]) + def dispatch(self): + import flask + body = flask.request.get_json() + return self. dispatch_with_args(body, argMap=dict()) + + def dispatch_with_args(self, body, argMap): + inputs = body.get('inputs', []) + state = body.get('state', []) + output = body['output'] + + 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]) + + return self.callback_map[target_id]['callback'](*args,**argMap) + + diff --git a/django_plotly_dash/views.py b/django_plotly_dash/views.py index 9610523e..9bae2891 100644 --- a/django_plotly_dash/views.py +++ b/django_plotly_dash/views.py @@ -1,6 +1,5 @@ from django.shortcuts import render -import flask import json from .dash_wrapper import get_app_instance_by_id, get_or_form_app @@ -26,13 +25,22 @@ def layout(request, id, **kwargs): def update(request, id, **kwargs): app = get_app_instance_by_id(id) - mFunc = app.locate_endpoint_function('dash-update-component') - # Fudge request object rb = json.loads(request.body.decode('utf-8')) - with app.test_request_context(): - # inputs state and output needed in the json objects - flask.request._cached_json = (rb, flask.request._cached_json[True]) - resp = mFunc() + + if app.use_dash_dispatch(): + # Force call through dash + mFunc = app.locate_endpoint_function('dash-update-component') + + import flask + with app.test_request_context(): + # Fudge request object + flask.request._cached_json = (rb, flask.request._cached_json[True]) + resp = mFunc() + else: + # Use direct dispatch + argMap = {} + resp = app.dispatch_with_args(rb, argMap) + return HttpResponse(resp.data, content_type=resp.mimetype) From a8343dd819dd09de0a615c2ce40b6269f1635a07 Mon Sep 17 00:00:00 2001 From: Mark Gibbs Date: Wed, 9 May 2018 22:38:20 -0700 Subject: [PATCH 03/12] Added callback variant with kwargs added onto function signature --- demo/demo/plotly_apps.py | 2 +- django_plotly_dash/dash_wrapper.py | 11 +++++++++-- django_plotly_dash/views.py | 8 +++++--- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/demo/demo/plotly_apps.py b/demo/demo/plotly_apps.py index f570b385..dee9c85e 100644 --- a/demo/demo/plotly_apps.py +++ b/demo/demo/plotly_apps.py @@ -44,7 +44,7 @@ def callback_size(dropdown_color, dropdown_size): html.Div(id="output-one") ]) -@a2.callback( +@a2.expanded_callback( dash.dependencies.Output('output-one','children'), [dash.dependencies.Input('dropdown-one','value')] ) diff --git a/django_plotly_dash/dash_wrapper.py b/django_plotly_dash/dash_wrapper.py index a69569a9..53ec5543 100644 --- a/django_plotly_dash/dash_wrapper.py +++ b/django_plotly_dash/dash_wrapper.py @@ -58,9 +58,12 @@ def __init__(self, name=None, **kwargs): global usable_apps usable_apps[self._uid] = self + self._expanded_callbacks = False + def form_dash_instance(self): rd = NotDash(name_root=self._uid, - app_pathname="%s:main" % app_name) + app_pathname="%s:main" % app_name, + expanded_callbacks = self._expanded_callbacks) rd.layout = self.layout for cb, func in self._callback_sets: @@ -82,6 +85,10 @@ def wrap_func(func,callback_set=callback_set,callback_sets=self._callback_sets): return func return wrap_func + def expanded_callback(self, output, inputs=[], state=[], events=[]): + self._expanded_callbacks = True + return self.callback(output, inputs, state, events) + class NotFlask: def __init__(self): self.config = {} @@ -124,7 +131,7 @@ def __init__(self, name_root, app_pathname, **kwargs): nd_apps[self._uid] = self self._adjust_id = False - self._dash_dispatch = False + self._dash_dispatch = not kwargs.get('expanded_callbacks',False) def use_dash_dispatch(self): # TODO make this be a function of using kwargs in callbacks diff --git a/django_plotly_dash/views.py b/django_plotly_dash/views.py index 9bae2891..d9767472 100644 --- a/django_plotly_dash/views.py +++ b/django_plotly_dash/views.py @@ -19,7 +19,7 @@ def dependencies(request, id, **kwargs): def layout(request, id, **kwargs): app = get_app_instance_by_id(id) mFunc = app.locate_endpoint_function('dash-layout') - resp = mFunc() + resp = mFunc() # bytes that is json encoded layout return HttpResponse(resp.data, content_type=resp.mimetype) @@ -37,8 +37,10 @@ def update(request, id, **kwargs): flask.request._cached_json = (rb, flask.request._cached_json[True]) resp = mFunc() else: - # Use direct dispatch - argMap = {} + # Use direct dispatch with extra arguments in the argMap + argMap = {'id':id, + 'request':request, + 'session':request.session} resp = app.dispatch_with_args(rb, argMap) return HttpResponse(resp.data, From a2db2fbd134d18dde3c5703f155939fb30a2a0e2 Mon Sep 17 00:00:00 2001 From: Mark Gibbs Date: Thu, 10 May 2018 13:40:04 -0700 Subject: [PATCH 04/12] Add on-the-fly local editing of initial app state --- django_plotly_dash/dash_wrapper.py | 50 ++++++++++++++++++++++++++++-- django_plotly_dash/views.py | 5 ++- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/django_plotly_dash/dash_wrapper.py b/django_plotly_dash/dash_wrapper.py index 53ec5543..19e8d3f8 100644 --- a/django_plotly_dash/dash_wrapper.py +++ b/django_plotly_dash/dash_wrapper.py @@ -2,6 +2,11 @@ from flask import Flask from django.urls import reverse +from django.http import HttpResponse + +import json + +from plotly.utils import PlotlyJSONEncoder from .app_name import app_name @@ -107,7 +112,7 @@ def run(self,*args,**kwargs): pass class NotDash(Dash): - def __init__(self, name_root, app_pathname, **kwargs): + def __init__(self, name_root, app_pathname=None, replacements = None, **kwargs): global app_instances current_instances = app_instances.get(name_root,None) @@ -132,11 +137,52 @@ def __init__(self, name_root, app_pathname, **kwargs): self._adjust_id = False self._dash_dispatch = not kwargs.get('expanded_callbacks',False) + if replacements: + self._replacements = replacements + else: + self._replacements = dict() + self._use_dash_layout = len(self._replacements) < 1 def use_dash_dispatch(self): - # TODO make this be a function of using kwargs in callbacks return self._dash_dispatch + def use_dash_layout(self): + return self._use_dash_layout + + def augment_initial_layout(self, base_response): + if self.use_dash_layout() and False: + return HttpResponse(base_response.data, + content_type=base_response.mimetype) + # Adjust the base layout response + baseDataInBytes = base_response.data + baseData = json.loads(baseDataInBytes.decode('utf-8')) + # Walk tree. If at any point we have an element whose id matches, then replace any named values at this level + reworked_data = self.walk_tree_and_replace(baseData) + response_data = json.dumps(reworked_data, + cls=PlotlyJSONEncoder) + return HttpResponse(response_data, + content_type=base_response.mimetype) + + def walk_tree_and_replace(self, data): + if isinstance(data,dict): + response = {} + replacements = {} + # look for id entry + thisID = data.get('id',None) + if thisID is not None: + replacements = self._replacements.get(thisID,{}) + # walk all keys and replace if needed + for k, v in data.items(): + r = replacements.get(k,None) + if r is None: + r = self.walk_tree_and_replace(v) + response[k] = r + return response + if isinstance(data,list): + # process each entry in turn and return + return [self.walk_tree_and_replace(x) for x in data] + return data + def flask_app(self): return self._flask_app diff --git a/django_plotly_dash/views.py b/django_plotly_dash/views.py index d9767472..caf088e1 100644 --- a/django_plotly_dash/views.py +++ b/django_plotly_dash/views.py @@ -19,9 +19,8 @@ def dependencies(request, id, **kwargs): def layout(request, id, **kwargs): app = get_app_instance_by_id(id) mFunc = app.locate_endpoint_function('dash-layout') - resp = mFunc() # bytes that is json encoded layout - return HttpResponse(resp.data, - content_type=resp.mimetype) + resp = mFunc() + return app.augment_initial_layout(resp) def update(request, id, **kwargs): app = get_app_instance_by_id(id) From eae96bb6e8408b81c2e292e3de654c3fcadd0c3d Mon Sep 17 00:00:00 2001 From: Mark Gibbs Date: Thu, 10 May 2018 16:25:53 -0700 Subject: [PATCH 05/12] Added DashApp model with associated migration --- demo/configdb.py | 17 +++++ demo/demo/templates/index.html | 2 +- django_plotly_dash/admin.py | 5 +- django_plotly_dash/app_name.py | 1 + django_plotly_dash/dash_wrapper.py | 45 ++++++++------ django_plotly_dash/migrations/0001_initial.py | 26 ++++++++ django_plotly_dash/migrations/__init__.py | 0 django_plotly_dash/models.py | 62 ++++++++++++++++++- .../templatetags/plotly_dash.py | 4 +- django_plotly_dash/urls.py | 4 +- django_plotly_dash/views.py | 12 ++-- prepare_demo | 1 + 12 files changed, 148 insertions(+), 31 deletions(-) create mode 100644 demo/configdb.py create mode 100644 django_plotly_dash/migrations/0001_initial.py create mode 100644 django_plotly_dash/migrations/__init__.py diff --git a/demo/configdb.py b/demo/configdb.py new file mode 100644 index 00000000..08fb0fd9 --- /dev/null +++ b/demo/configdb.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python +# +# Create a new superuser +from django.contrib.auth import get_user_model + +UserModel = get_user_model() + +name="admin" +password="admin" + +try: + UserModel.objects.get(username=name) +except: + su = UserModel.objects.create_user(name,password=password) + su.is_staff=True + su.is_superuser=True + su.save() diff --git a/demo/demo/templates/index.html b/demo/demo/templates/index.html index 7749878a..3bc1150b 100644 --- a/demo/demo/templates/index.html +++ b/demo/demo/templates/index.html @@ -5,7 +5,7 @@
Content here - {%plotly_item "SimpleExample"%} + {%plotly_item "simpleexample-1"%}
Content here diff --git a/django_plotly_dash/admin.py b/django_plotly_dash/admin.py index 8c38f3f3..4580ac5b 100644 --- a/django_plotly_dash/admin.py +++ b/django_plotly_dash/admin.py @@ -1,3 +1,6 @@ from django.contrib import admin -# Register your models here. +from .models import DashApp, DashAppAdmin + +admin.site.register(DashApp, DashAppAdmin) + diff --git a/django_plotly_dash/app_name.py b/django_plotly_dash/app_name.py index 9895108d..bcf13f1e 100644 --- a/django_plotly_dash/app_name.py +++ b/django_plotly_dash/app_name.py @@ -1 +1,2 @@ app_name = "the_django_plotly_dash" +main_view_label = "main" diff --git a/django_plotly_dash/dash_wrapper.py b/django_plotly_dash/dash_wrapper.py index 19e8d3f8..bb168a78 100644 --- a/django_plotly_dash/dash_wrapper.py +++ b/django_plotly_dash/dash_wrapper.py @@ -8,14 +8,21 @@ from plotly.utils import PlotlyJSONEncoder -from .app_name import app_name +from .app_name import app_name, main_view_label uid_counter = 0 usable_apps = {} -app_instances = {} 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): ''' Locate a registered dash app by name, and return a DelayedDash instance encapsulating the app. @@ -28,6 +35,9 @@ def get_app_instance_by_id(id): ''' return nd_apps.get(id,None) +def clear_app_instance(id): + del nd_apps[id] + 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 @@ -60,15 +70,17 @@ def __init__(self, name=None, **kwargs): self.css = Holder() self.scripts = Holder() - global usable_apps - usable_apps[self._uid] = self + add_usable_app(self._uid, + self) self._expanded_callbacks = False - def form_dash_instance(self): + def form_dash_instance(self, replacements=None, specific_identifier=None): rd = NotDash(name_root=self._uid, - app_pathname="%s:main" % app_name, - expanded_callbacks = self._expanded_callbacks) + app_pathname="%s:%s" % (app_name, main_view_label), + expanded_callbacks = self._expanded_callbacks, + replacements = replacements, + specific_identifier = specific_identifier) rd.layout = self.layout for cb, func in self._callback_sets: @@ -112,17 +124,14 @@ def run(self,*args,**kwargs): pass class NotDash(Dash): - def __init__(self, name_root, app_pathname=None, replacements = None, **kwargs): + def __init__(self, name_root, app_pathname=None, replacements = None, specific_identifier=None, expanded_callbacks=False, **kwargs): - global app_instances - current_instances = app_instances.get(name_root,None) - - if current_instances is not None: - self._uid = "%s-%i" % (name_root,len(current_instances)+1) - current_instances.append(self) + if specific_identifier is not None: + self._uid = specific_identifier else: self._uid = name_root - app_instances[name_root] = [self,] + + add_instance(self._uid, self) self._flask_app = Flask(self._uid) self._notflask = NotFlask() @@ -132,11 +141,9 @@ def __init__(self, name_root, app_pathname=None, replacements = None, **kwargs): kwargs['server'] = self._notflask super(NotDash, self).__init__(**kwargs) - global nd_apps - nd_apps[self._uid] = self self._adjust_id = False - self._dash_dispatch = not kwargs.get('expanded_callbacks',False) + self._dash_dispatch = not expanded_callbacks if replacements: self._replacements = replacements else: @@ -164,6 +171,8 @@ def augment_initial_layout(self, base_response): content_type=base_response.mimetype) def walk_tree_and_replace(self, data): + # Walk the tree. Rely on json decoding to insert instances of dict and list + # ie we use a dna test for anatine, rather than our eyes and ears... if isinstance(data,dict): response = {} replacements = {} diff --git a/django_plotly_dash/migrations/0001_initial.py b/django_plotly_dash/migrations/0001_initial.py new file mode 100644 index 00000000..8cb034e5 --- /dev/null +++ b/django_plotly_dash/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# Generated by Django 2.0.5 on 2018-05-10 21:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='DashApp', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('app_name', models.CharField(max_length=100)), + ('instance_name', models.CharField(blank=True, max_length=100, unique=True)), + ('slug', models.SlugField(blank=True, max_length=110, unique=True)), + ('base_state', models.TextField()), + ('creation', models.DateTimeField(auto_now_add=True)), + ('update', models.DateTimeField(auto_now=True)), + ], + ), + ] diff --git a/django_plotly_dash/migrations/__init__.py b/django_plotly_dash/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/django_plotly_dash/models.py b/django_plotly_dash/models.py index 71a83623..0e29dc8d 100644 --- a/django_plotly_dash/models.py +++ b/django_plotly_dash/models.py @@ -1,3 +1,63 @@ from django.db import models +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 + +import json + +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) # If mandating postgresql then this could be a JSONField + creation = models.DateTimeField(auto_now_add=True) + update = models.DateTimeField(auto_now=True) + + def __str__(self): + return self.instance_name + + def save(self, *args, **kwargs): + if not self.instance_name: + existing_count = DashApp.objects.filter(app_name=self.app_name).count() + self.instance_name = "%s-%i" %(self.app_name, existing_count+1) + 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 as_dash_instance(self): + ai = get_app_instance_by_id(self.slug) + if ai: + return ai + dd = get_app_by_name(self.app_name) + 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() + +class DashAppAdmin(admin.ModelAdmin): + list_display = ['app_name','instance_name','slug','creation','update',] + list_filter = ['app_name','creation','update',] -# Create your models here. diff --git a/django_plotly_dash/templatetags/plotly_dash.py b/django_plotly_dash/templatetags/plotly_dash.py index 935931d2..a96dd8a1 100644 --- a/django_plotly_dash/templatetags/plotly_dash.py +++ b/django_plotly_dash/templatetags/plotly_dash.py @@ -2,11 +2,11 @@ register = template.Library() -from django_plotly_dash.dash_wrapper import get_or_form_app +from django_plotly_dash.models import DashApp @register.inclusion_tag("django_plotly_dash/plotly_item.html", takes_context=True) def plotly_item(context, app_name): - app = get_or_form_app(app_name, app_name) + app = DashApp.get_app_instance(app_name) return locals() diff --git a/django_plotly_dash/urls.py b/django_plotly_dash/urls.py index 64a7ee53..1d6696b6 100644 --- a/django_plotly_dash/urls.py +++ b/django_plotly_dash/urls.py @@ -3,7 +3,7 @@ from .views import routes, layout, dependencies, update, main_view -from .app_name import app_name +from .app_name import app_name, main_view_label urlpatterns = [ path('_dash-routes', routes, name="routes"), @@ -11,6 +11,6 @@ path('_dash-dependencies', dependencies, name="dependencies"), path('_dash-update-component', csrf_exempt(update), name="update-component"), - path('', main_view, name="main"), + path('', main_view, name=main_view_label), ] diff --git a/django_plotly_dash/views.py b/django_plotly_dash/views.py index caf088e1..1f02a7f3 100644 --- a/django_plotly_dash/views.py +++ b/django_plotly_dash/views.py @@ -1,15 +1,15 @@ from django.shortcuts import render +from django.http import HttpResponse import json -from .dash_wrapper import get_app_instance_by_id, get_or_form_app -from django.http import HttpResponse +from .models import DashApp def routes(*args,**kwargs): pass def dependencies(request, id, **kwargs): - app = get_app_instance_by_id(id) + app = DashApp.get_app_instance(id) with app.app_context(): mFunc = app.locate_endpoint_function('dash-dependencies') resp = mFunc() @@ -17,13 +17,13 @@ def dependencies(request, id, **kwargs): content_type=resp.mimetype) def layout(request, id, **kwargs): - app = get_app_instance_by_id(id) + app = DashApp.get_app_instance(id) mFunc = app.locate_endpoint_function('dash-layout') resp = mFunc() return app.augment_initial_layout(resp) def update(request, id, **kwargs): - app = get_app_instance_by_id(id) + app = DashApp.get_app_instance(id) rb = json.loads(request.body.decode('utf-8')) if app.use_dash_dispatch(): @@ -46,7 +46,7 @@ def update(request, id, **kwargs): content_type=resp.mimetype) def main_view(request, id, **kwargs): - app = get_or_form_app(id, id) + app = DashApp.get_app_instance(id) mFunc = app.locate_endpoint_function() resp = mFunc() return HttpResponse(resp) diff --git a/prepare_demo b/prepare_demo index 55c17b77..2312ea7d 100755 --- a/prepare_demo +++ b/prepare_demo @@ -3,4 +3,5 @@ source env/bin/activate cd demo ./manage.py migrate +./manage.py shell < configdb.py # Add a superuser if needed ./manage.py runserver From ef27a17b8e4fca991b8e9f75cedf8fd5910382f6 Mon Sep 17 00:00:00 2001 From: Mark Gibbs Date: Thu, 10 May 2018 16:39:21 -0700 Subject: [PATCH 06/12] Added data migration for simple example --- README.md | 2 +- demo/demo/settings.py | 2 +- django_plotly_dash/apps.py | 1 + .../migrations/0002_simple_example_state.py | 28 +++++++++++++++++++ docs/installation.rst | 10 +++++-- 5 files changed, 38 insertions(+), 5 deletions(-) create mode 100644 django_plotly_dash/migrations/0002_simple_example_state.py diff --git a/README.md b/README.md index 6286b561..3d4287a6 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Then, just add `django_plotly_dash` to `INSTALLED_APPS` in your Django `settings INSTALLED_APPS = [ ... - 'django_plotly_dash', + 'django_plotly_dash.apps.DjangoPlotlyDashConfig', ... ] diff --git a/demo/demo/settings.py b/demo/demo/settings.py index 08964954..aeae9699 100644 --- a/demo/demo/settings.py +++ b/demo/demo/settings.py @@ -38,7 +38,7 @@ 'django.contrib.messages', 'django.contrib.staticfiles', - 'django_plotly_dash', + 'django_plotly_dash.apps.DjangoPlotlyDashConfig', ] MIDDLEWARE = [ diff --git a/django_plotly_dash/apps.py b/django_plotly_dash/apps.py index 5109ed21..e5e5fa7a 100644 --- a/django_plotly_dash/apps.py +++ b/django_plotly_dash/apps.py @@ -3,3 +3,4 @@ class DjangoPlotlyDashConfig(AppConfig): name = 'django_plotly_dash' + verbose_name = "Django Plotly Dash" diff --git a/django_plotly_dash/migrations/0002_simple_example_state.py b/django_plotly_dash/migrations/0002_simple_example_state.py new file mode 100644 index 00000000..c6cef1e3 --- /dev/null +++ b/django_plotly_dash/migrations/0002_simple_example_state.py @@ -0,0 +1,28 @@ +# Generated by Django 2.0.5 on 2018-05-10 23:26 + +from django.db import migrations + +def getDA(apps): + return apps.get_model("django_plotly_dash","DashApp") + +def forward(apps, schema_editor): + DashApp = getDA(apps) + da = DashApp(app_name="SimpleExample", + instance_name="SimpleExample-1", + slug="simpleexample-1", + base_state='{"dropdown-color":{"value":"blue"},"dropdown-size":{"value":"small"}}') + da.save() + +def backward(apps, schema_editor): + DashApp = getDA(apps) + DashApp.objects.all().delete() + +class Migration(migrations.Migration): + + dependencies = [ + ('django_plotly_dash', '0001_initial'), + ] + + operations = [ + migrations.RunPython(forward, backward), + ] diff --git a/docs/installation.rst b/docs/installation.rst index ee12c752..483a8f7d 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -11,15 +11,18 @@ Then, add ``django_plotly_dash`` to ``INSTALLED_APPS`` in the Django settings.py INSTALLED_APPS = [ ... - 'django_plotly_dash', + 'django_plotly_dash.apps.DjangoPlotlyDashConfig', ... ] -The plotly_item tag in the plotly_dash tag library can then be used to render any registered dash component. See :ref:`simple_use` for a simple example. +The ``plotly_item`` tag in the ``plotly_dash`` tag library can then be used to render any registered dash component. See :ref:`simple_use` for a simple example. + +The project directory name ``django_plotly_dash`` can also be used on its own if preferred, but this will then skip the use of readable application names in +the Django admin interface. Source code and demo -------------------- - +2 The source code repository contains a simple demo application. To install and run it:: @@ -36,3 +39,4 @@ To install and run it:: # using the Django debug server # at http://localhost:8000 +This will launch a simple Django application. A superuser account is also configured, with both username and password set to ``admin``. From b1478e34b5aad4cb1ade357fc56e71426733adb3 Mon Sep 17 00:00:00 2001 From: Mark Gibbs Date: Fri, 11 May 2018 06:50:31 -0700 Subject: [PATCH 07/12] Documentation tweaks --- docs/installation.rst | 2 +- docs/simple_use.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/installation.rst b/docs/installation.rst index 483a8f7d..b745f6b0 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -22,7 +22,7 @@ the Django admin interface. Source code and demo -------------------- -2 + The source code repository contains a simple demo application. To install and run it:: diff --git a/docs/simple_use.rst b/docs/simple_use.rst index dad3d554..b0b1a8c1 100644 --- a/docs/simple_use.rst +++ b/docs/simple_use.rst @@ -4,7 +4,7 @@ Simple Usage ============ To use existing dash applications, first register them using the ``DelayedDash`` class. This -replaces the ``Dash`` class of `dash`` +replaces the ``Dash`` class from the ``dash`` package. Taking a simple example inspired by the excellent `getting started `_ guide:: From 2f0d77b4a1ef31b0fc9051a431a54f0e8437b55c Mon Sep 17 00:00:00 2001 From: Mark Gibbs Date: Fri, 11 May 2018 11:23:41 -0700 Subject: [PATCH 08/12] Made iframe in template be responsive --- demo/demo/templates/index.html | 4 ++-- .../django_plotly_dash/plotly_item.html | 4 ++-- django_plotly_dash/templatetags/dexy.py | 13 ------------- .../templatetags/plotly_dash.py | 19 ++++++++++++++++++- 4 files changed, 22 insertions(+), 18 deletions(-) delete mode 100644 django_plotly_dash/templatetags/dexy.py diff --git a/demo/demo/templates/index.html b/demo/demo/templates/index.html index 3bc1150b..22733a85 100644 --- a/demo/demo/templates/index.html +++ b/demo/demo/templates/index.html @@ -3,7 +3,7 @@ {%load plotly_dash%} Simple stuff -
+
Content here {%plotly_item "simpleexample-1"%}
@@ -11,7 +11,7 @@ Content here {%plotly_item "SimpleExample"%}
-
+
Content here {%plotly_item "Ex2"%}
diff --git a/django_plotly_dash/templates/django_plotly_dash/plotly_item.html b/django_plotly_dash/templates/django_plotly_dash/plotly_item.html index 2568dd75..bbe22d2c 100644 --- a/django_plotly_dash/templates/django_plotly_dash/plotly_item.html +++ b/django_plotly_dash/templates/django_plotly_dash/plotly_item.html @@ -1,3 +1,3 @@ -
- +
+
diff --git a/django_plotly_dash/templatetags/dexy.py b/django_plotly_dash/templatetags/dexy.py deleted file mode 100644 index 52fce6e5..00000000 --- a/django_plotly_dash/templatetags/dexy.py +++ /dev/null @@ -1,13 +0,0 @@ -from django import template - -register = template.Library() - -from django_plotly_dash.dash_wrapper import get_app_by_name - -@register.inclusion_tag("django_plotly_dash/plotly_item.html", takes_context=True) -def plotly_item(context, app_name): - - app = get_app_by_name(app_name) - url = app.base_url() - - return locals() diff --git a/django_plotly_dash/templatetags/plotly_dash.py b/django_plotly_dash/templatetags/plotly_dash.py index a96dd8a1..567f6e56 100644 --- a/django_plotly_dash/templatetags/plotly_dash.py +++ b/django_plotly_dash/templatetags/plotly_dash.py @@ -5,7 +5,24 @@ from django_plotly_dash.models import DashApp @register.inclusion_tag("django_plotly_dash/plotly_item.html", takes_context=True) -def plotly_item(context, app_name): +def plotly_item(context, app_name, ratio=0.1, use_frameborder=False): + + fbs = use_frameborder and '1' or '0' + + dstyle = """ + position: relative; + padding-bottom: %s%%; + height: 0; + overflow:hidden; + """ % (ratio*100) + + istyle = """ + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + """ app = DashApp.get_app_instance(app_name) From eb06c989cedc59e5ea179355b256d0d960cf4bbd Mon Sep 17 00:00:00 2001 From: Mark Gibbs Date: Fri, 11 May 2018 14:34:57 -0700 Subject: [PATCH 09/12] Added admin step to save base state of dash app --- demo/demo/templates/index.html | 6 ++--- django_plotly_dash/dash_wrapper.py | 21 +++++++++++++++++- django_plotly_dash/models.py | 35 +++++++++++++++++++++++++++++- django_plotly_dash/views.py | 3 +++ 4 files changed, 60 insertions(+), 5 deletions(-) diff --git a/demo/demo/templates/index.html b/demo/demo/templates/index.html index 22733a85..2ad76c99 100644 --- a/demo/demo/templates/index.html +++ b/demo/demo/templates/index.html @@ -3,15 +3,15 @@ {%load plotly_dash%} Simple stuff -
+
Content here - {%plotly_item "simpleexample-1"%} + {%plotly_item "simpleexample-1" ratio=0.2 %}
Content here {%plotly_item "SimpleExample"%}
-
+
Content here {%plotly_item "Ex2"%}
diff --git a/django_plotly_dash/dash_wrapper.py b/django_plotly_dash/dash_wrapper.py index bb168a78..0f140238 100644 --- a/django_plotly_dash/dash_wrapper.py +++ b/django_plotly_dash/dash_wrapper.py @@ -36,7 +36,10 @@ def get_app_instance_by_id(id): return nd_apps.get(id,None) def clear_app_instance(id): - del nd_apps[id] + try: + del nd_apps[id] + except: + pass def get_or_form_app(id, name, **kwargs): ''' @@ -170,6 +173,22 @@ def augment_initial_layout(self, base_response): return HttpResponse(response_data, content_type=base_response.mimetype) + def walk_tree_and_extract(self, data, target): + if isinstance(data, dict): + for key in ['children','props',]: + self.walk_tree_and_extract(data.get(key,None),target) + ident = data.get('id', None) + if ident is not None: + idVals = target.get(ident,{}) + for key, value in data.items(): + if key not in ['props','options','children','id']: + idVals[key] = value + if len(idVals) > 0: + target[ident] = idVals + if isinstance(data, list): + for element in data: + self.walk_tree_and_extract(element, target) + def walk_tree_and_replace(self, data): # Walk the tree. Rely on json decoding to insert instances of dict and list # ie we use a dna test for anatine, rather than our eyes and ears... diff --git a/django_plotly_dash/models.py b/django_plotly_dash/models.py index 0e29dc8d..aee3221d 100644 --- a/django_plotly_dash/models.py +++ b/django_plotly_dash/models.py @@ -57,7 +57,40 @@ def get_app_instance(id): 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_resp = base_app_inst.locate_endpoint_function('dash-layout')() + + base_obj = json.loads(base_resp.data.decode('utf-8')) + + # Walk the base layout and find all values; insert into base state map + obj = {} + base_app_inst.walk_tree_and_extract(base_obj, obj) + return obj + + def populate_values(self): + ''' + Add values from the underlying dash layout configuration + ''' + obj = self._get_base_state() + self.base_state = json.dumps(obj) + class DashAppAdmin(admin.ModelAdmin): - list_display = ['app_name','instance_name','slug','creation','update',] + list_display = ['instance_name','app_name','slug','creation','update',] list_filter = ['app_name','creation','update',] + def _populate_values(self, request, queryset): + for da in queryset: + da.populate_values() + da.save() + _populate_values.short_description = "Populate app" + + actions = ['_populate_values',] diff --git a/django_plotly_dash/views.py b/django_plotly_dash/views.py index 1f02a7f3..a5caac6c 100644 --- a/django_plotly_dash/views.py +++ b/django_plotly_dash/views.py @@ -37,10 +37,13 @@ def update(request, id, **kwargs): resp = mFunc() else: # Use direct dispatch with extra arguments in the argMap + print("Update args") + print(rb) argMap = {'id':id, 'request':request, 'session':request.session} resp = app.dispatch_with_args(rb, argMap) + print(resp.data) return HttpResponse(resp.data, content_type=resp.mimetype) From de0840b5f4212105e6a443448b42d19e1dbea07e Mon Sep 17 00:00:00 2001 From: Mark Gibbs Date: Fri, 11 May 2018 15:28:23 -0700 Subject: [PATCH 10/12] Improve view code --- django_plotly_dash/views.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/django_plotly_dash/views.py b/django_plotly_dash/views.py index a5caac6c..7247992c 100644 --- a/django_plotly_dash/views.py +++ b/django_plotly_dash/views.py @@ -37,13 +37,9 @@ def update(request, id, **kwargs): resp = mFunc() else: # Use direct dispatch with extra arguments in the argMap - print("Update args") - print(rb) argMap = {'id':id, - 'request':request, 'session':request.session} resp = app.dispatch_with_args(rb, argMap) - print(resp.data) return HttpResponse(resp.data, content_type=resp.mimetype) From 4e863fb293371effb4aafa2268aa51b155b77bdf Mon Sep 17 00:00:00 2001 From: Mark Gibbs Date: Mon, 14 May 2018 11:21:25 -0700 Subject: [PATCH 11/12] Minor doc tweaks --- README.md | 2 +- docs/simple_use.rst | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3d4287a6..edfc9794 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,6 @@ templates: ``` The registration code needs to be in a location -that will be imported into the Django process before any template tag attempts to use it. The example Django application +that will be imported into the Django process before any model or template tag attempts to use it. The example Django application in the demo subdirectory achieves this through an import in the main urls.py file; any views.py would also be sufficient. diff --git a/docs/simple_use.rst b/docs/simple_use.rst index b0b1a8c1..a7fae581 100644 --- a/docs/simple_use.rst +++ b/docs/simple_use.rst @@ -55,6 +55,7 @@ templates::: {%plotly_item "SimpleExample"%} The registration code needs to be in a location -that will be imported into the Django process before any template tag attempts to use it. The example Django application +that will be imported into the Django process before any model or +template tag attempts to use it. The example Django application in the demo subdirectory achieves this through an import in the main urls.py file; any views.py would also be sufficient. From f4e28aff0a3b007b3439e61b05d39a5f33bfa9ce Mon Sep 17 00:00:00 2001 From: Mark Gibbs Date: Mon, 14 May 2018 11:34:11 -0700 Subject: [PATCH 12/12] Bump version --- 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 3eb7c6b7..21100bbf 100644 --- a/django_plotly_dash/__init__.py +++ b/django_plotly_dash/__init__.py @@ -1,6 +1,6 @@ # -__version__ = "0.0.3" +__version__ = "0.0.4" from .dash_wrapper import DelayedDash