From c50decc7028f0dc8825632e551da94212afb8027 Mon Sep 17 00:00:00 2001
From: Mark Gibbs <delsim@users.noreply.github.com>
Date: Tue, 9 Apr 2019 22:49:16 -0700
Subject: [PATCH] Serve locally (#133)

* Local serving of assets

* Demo nine to show and test use of local assets.

* Move to serving dash components assets through staticfiles, and steps towards serving other files locally through url rewriting

* Constrain dash component versions

* Add static support to dev environment for demo

* Increased test coverage
---
 .gitignore                           |   2 +-
 demo/demo/assets/image_one.png       | Bin 0 -> 989 bytes
 demo/demo/plotly_apps.py             |  11 ++++-
 demo/demo/settings.py                |  11 ++++-
 demo/demo/templates/demo_nine.html   |  33 ++++++++++++++
 demo/demo/templates/index.html       |   1 +
 demo/demo/urls.py                    |   1 +
 dev_requirements.txt                 |   4 +-
 django_plotly_dash/assets/some_asset |   0
 django_plotly_dash/dash_wrapper.py   |  48 +++++++++++++++++---
 django_plotly_dash/finders.py        |  59 +++++++++++++++++++++---
 django_plotly_dash/middleware.py     |  43 ++++++++++++++++++
 django_plotly_dash/tests.py          |  37 +++++++++++++++
 django_plotly_dash/urls.py           |   3 +-
 django_plotly_dash/util.py           |  16 +++++++
 django_plotly_dash/views.py          |  14 +++++-
 docs/configuration.rst               |  41 +++++++++++++++++
 docs/faq.rst                         |  25 +++++++++++
 docs/index.rst                       |   1 +
 docs/installation.rst                |  11 +++--
 docs/local_assets.rst                |  65 +++++++++++++++++++++++++++
 prepare_demo                         |   5 ++-
 requirements.txt                     |  10 ++---
 23 files changed, 412 insertions(+), 29 deletions(-)
 create mode 100644 demo/demo/assets/image_one.png
 create mode 100644 demo/demo/templates/demo_nine.html
 create mode 100644 django_plotly_dash/assets/some_asset
 create mode 100644 docs/local_assets.rst

diff --git a/.gitignore b/.gitignore
index 6bc3d3d..8a683ef 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,7 +54,7 @@ coverage.xml
 *.log
 local_settings.py
 */db.sqlite3
-demo/static/*
+demo/staticfiles/*
 
 # Flask stuff:
 instance/
diff --git a/demo/demo/assets/image_one.png b/demo/demo/assets/image_one.png
new file mode 100644
index 0000000000000000000000000000000000000000..6b839e88512e88de381b09ea0f0fc6072e2c42a9
GIT binary patch
literal 989
zcmeAS@N?(olHy`uVBq!ia0y~yU`PRB4mJh`hJr^^Ll_ts*pj^6T^Rm@;DWu&Co?cG
za29w(7Bet#3xhBt!>l<H3=9nHC7!;n?2lR41eA@QZQswxz`!6`;u=xnoS&PUnpeW$
zT$GwvlA5AWo>`Ki;O^-g5Z=fq&cMLz>gnPbQgQ3;T}N-FM22G@kGpoOX`b*7sM1_0
zae9K%t|#Rj{<793uZ3EctGwbCxhu4*MB^8eBCBjuho7TDNrzwH#e)-PhN~P**VnxL
zDyQ^D#`Nd>%toJ$EdI?Xuh}c1;JG6IGJh=#Qy`OIhfs&Z6bF_?EJ^~Z0;&Ry8jTz-
zoGuDp3QU0%O8<0tS|pjBo$aAEnWOzMNBdz3d3kre>CKNV{`YhIZ|pj%l)O=bam(GL
zjS}5QmHxNyv+Xc!`&coDVTO-dWl4$4_0pG>a@?&<>S}5n4IeA!9Fu7L^VZDVoU>u^
z;>9Jg+n?@uy-P+?QjsAqH+S#e9oMZ-NE~i$W&QT$i-Az5!R)h};?{4UlbxU6|FUG(
z-o1O@ym;~8!Uciwu&@^veGXD*&z%!u;OFD3IsWGTdtrvlFH52)_^4erI-8rHZ+`sh
z>(X=Q&;R!8+?KmJMvuEi<@c{&91Z^d{`->U<m5OSCVDK<_0XF7=<8Qg+2ao%JyK#U
zC@R`ysW<<;FatX~du2t%iRaJL!?Z-_&7WVKmDR;C<7^tM)1-q56Br^^U#&U5XU`r1
zhUS9_(G$A6yPxg*{kxhep}f4j`2LqKCX656yjk<~&i3uw&!$gtu&Zddv9tU4k#A+#
z>ilKPkNtC-mip5#yjNF*tM=sAy5FT9N)s6v=FFS-t1nX~yXWuTbLY-Id+nfb;oa*0
zyWcMkTb-H6!BAXW%+T=lYpFzXRG)P6hK(CProBnuoGZ;xP+8e|J#z8eWvz;y=RaH6
z*xaeH5a(j8n&+O()8F4e(`#wXb;bSuzIDIL%gZ?%{{H=Y=K1G6yLTrq4NBY^_4Mpn
z-tw|C3uEKO=_l4}f6Xv?Wxd~h@x^W0U;CRTJh^n~((dzujemkxh9v1te|+PHgzo7@
zsgXV^LNT$isVhUW>I>&HD4v`=Svbu|l8=YSK!j`K>eb!ba*Hb~XNHG|#~00I{Hr;m
j?rkW^$#li}ufnz6I+7wc8a^{HFfe$!`njxgN@xNAjP97M

literal 0
HcmV?d00001

diff --git a/demo/demo/plotly_apps.py b/demo/demo/plotly_apps.py
index 9ca7346..de3952a 100644
--- a/demo/demo/plotly_apps.py
+++ b/demo/demo/plotly_apps.py
@@ -84,6 +84,7 @@ def callback_size(dropdown_color, dropdown_size):
 
 a2 = DjangoDash("Ex2",
                 serve_locally=True)
+
 a2.layout = html.Div([
     dcc.RadioItems(id="dropdown-one",
                    options=[{'label':i, 'value':j} for i, j in [("O2", "Oxygen"),
@@ -186,8 +187,7 @@ def callback_liveIn_button_press(red_clicks, blue_clicks, green_clicks,
                                                                                              change_col,
                                                                                              datetime.fromtimestamp(0.001*timestamp))
 
-liveOut = DjangoDash("LiveOutput",
-                    )#serve_locally=True)
+liveOut = DjangoDash("LiveOutput")
 
 def _get_cache_key(state_uid):
     return "demo-liveout-s6-%s" % state_uid
@@ -309,3 +309,10 @@ def callback_show_timeseries(internal_state_string, state_uid, **kwargs):
     return {'data':traces,
             #'layout': go.Layout
            }
+
+localState = DjangoDash("LocalState",
+                        serve_locally=True)
+
+localState.layout = html.Div([html.Img(src=localState.get_asset_url('image_one.png')),
+                              html.Img(src='assets/image_two.png'),
+                              ])
diff --git a/demo/demo/settings.py b/demo/demo/settings.py
index e906b6a..a24315d 100644
--- a/demo/demo/settings.py
+++ b/demo/demo/settings.py
@@ -42,10 +42,14 @@ INSTALLED_APPS = [
     'bootstrap4',
 
     'django_plotly_dash.apps.DjangoPlotlyDashConfig',
+    'dpd_static_support',
 ]
 
 MIDDLEWARE = [
     'django.middleware.security.SecurityMiddleware',
+
+    'whitenoise.middleware.WhiteNoiseMiddleware',
+
     'django.contrib.sessions.middleware.SessionMiddleware',
     'django.middleware.common.CommonMiddleware',
     'django.middleware.csrf.CsrfViewMiddleware',
@@ -53,6 +57,7 @@ MIDDLEWARE = [
     'django.contrib.messages.middleware.MessageMiddleware',
 
     'django_plotly_dash.middleware.BaseMiddleware',
+    'django_plotly_dash.middleware.ExternalRedirectionMiddleware',
 
     'django.middleware.clickjacking.XFrameOptionsMiddleware',
 ]
@@ -134,13 +139,15 @@ PLOTLY_DASH = {
     "view_decorator" : None, # Specify a function to be used to wrap each of the dpd view functions
 
     "cache_arguments" : True, # True for cache, False for session-based argument propagation
+
+    #"serve_locally" : True, # True to serve assets locally, False to use their unadulterated urls (eg a CDN)
     }
 
 # Static files (CSS, JavaScript, Images)
 # https://docs.djangoproject.com/en/2.0/howto/static-files/
 
 STATIC_URL = '/static/'
-STATIC_ROOT = os.path.join(BASE_DIR, 'static')
+STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
 
 STATICFILES_DIRS = [
     os.path.join(BASE_DIR, 'demo', 'static'),
@@ -178,6 +185,7 @@ STATICFILES_FINDERS = [
 
     'django_plotly_dash.finders.DashAssetFinder',
     'django_plotly_dash.finders.DashComponentFinder',
+    'django_plotly_dash.finders.DashAppDirectoryFinder',
 ]
 
 # Plotly components containing static content that should
@@ -189,4 +197,5 @@ PLOTLY_COMPONENTS = [
     'dash_bootstrap_components',
     'dash_renderer',
     'dpd_components',
+    'dpd_static_support',
 ]
diff --git a/demo/demo/templates/demo_nine.html b/demo/demo/templates/demo_nine.html
new file mode 100644
index 0000000..d21244e
--- /dev/null
+++ b/demo/demo/templates/demo_nine.html
@@ -0,0 +1,33 @@
+{%extends "base.html"%}
+{%load plotly_dash%}
+
+{%block title%}Demo Nine - Serving local assets{%endblock%}
+
+{%block content%}
+<h1>Serving Local Assets</h1>
+<p>
+  This example demonstrates serving local assets as part of a Dash app.
+</p>
+<p>
+  The extra files are specified using the standard plotly dash approach, and are
+  made available through the standard Django staticfiles infrastructure. This means that
+  they will be served the same way as other static files through a reverse proxy.
+</p>
+<p></p>
+<div class="card bg-light border-dark">
+  <div class="card-body">
+    <p><span>{</span>% load plotly_dash %}</p>
+    <p>&lt;div class="<span>{</span>% plotly_class name="LocalState"%}">
+    <p class="ml-3"><span>{</span>% plotly_app name="LocalState" ratio=0.3 %}</p>
+    <p>&lt;\div>
+  </div>
+</div>
+<p></p>
+<div class="card border-dark">
+  <div class="card-body">
+    <div class="{%plotly_class name="LocalState"%}">
+      {%plotly_app name="LocalState" ratio=0.3 %}
+    </div>
+  </div>
+</div>
+{%endblock%}
diff --git a/demo/demo/templates/index.html b/demo/demo/templates/index.html
index d6e727a..f020630 100644
--- a/demo/demo/templates/index.html
+++ b/demo/demo/templates/index.html
@@ -15,5 +15,6 @@
     <li><a class="btn btn-primary btnspace" href="{%url "demo-six"%}">Demo Six</a> - simple html injection example</li>
     <li><a class="btn btn-primary btnspace" href="{%url "demo-seven"%}">Demo Seven</a> - dash-bootstrap-components example</li>
     <li><a class="btn btn-primary btnspace" href="{%url "demo-eight"%}">Demo Eight</a> - Django session state example</li>
+    <li><a class="btn btn-primary btnspace" href="{%url "demo-nine"%}">Demo Nine</a> - local serving of assets</li>
   </ul>
 {%endblock%}
diff --git a/demo/demo/urls.py b/demo/demo/urls.py
index 0db3cf9..0e4f215 100644
--- a/demo/demo/urls.py
+++ b/demo/demo/urls.py
@@ -44,6 +44,7 @@ urlpatterns = [
     url('^demo-six', dash_example_1_view, name="demo-six"),
     url('^demo-seven', TemplateView.as_view(template_name='demo_seven.html'), name="demo-seven"),
     url('^demo-eight', session_state_view, {'template_name':'demo_eight.html'}, name="demo-eight"),
+    url('^demo-nine', TemplateView.as_view(template_name='demo_nine.html'), name="demo-nine"),
     url('^admin/', admin.site.urls),
     url('^django_plotly_dash/', include('django_plotly_dash.urls')),
 
diff --git a/dev_requirements.txt b/dev_requirements.txt
index a943b78..3ee1d37 100644
--- a/dev_requirements.txt
+++ b/dev_requirements.txt
@@ -1,9 +1,10 @@
 channels>=2.0
 channels-redis
 daphne
-Django>=2.0
+Django>=2.0,<2.2
 django-bootstrap4
 django-redis
+dpd-static-support
 grip
 pandas
 pylint
@@ -16,4 +17,5 @@ redis
 sphinx
 sphinx-autobuild
 twine
+whitenoise
 
diff --git a/django_plotly_dash/assets/some_asset b/django_plotly_dash/assets/some_asset
new file mode 100644
index 0000000..e69de29
diff --git a/django_plotly_dash/dash_wrapper.py b/django_plotly_dash/dash_wrapper.py
index e0177f8..47ba434 100644
--- a/django_plotly_dash/dash_wrapper.py
+++ b/django_plotly_dash/dash_wrapper.py
@@ -39,6 +39,8 @@ from plotly.utils import PlotlyJSONEncoder
 from .app_name import app_name, main_view_label
 from .middleware import EmbeddedHolder
 
+from .util import static_asset_path
+from .util import serve_locally as serve_locally_setting
 
 uid_counter = 0
 
@@ -84,11 +86,12 @@ class DjangoDash:
     To use, construct an instance of DjangoDash() in place of a Dash() one.
     '''
     #pylint: disable=too-many-instance-attributes
-    def __init__(self, name=None, serve_locally=False,
+    def __init__(self, name=None, serve_locally=None,
                  expanded_callbacks=False,
                  add_bootstrap_links=False,
                  suppress_callback_exceptions=False,
                  **kwargs): # pylint: disable=unused-argument, too-many-arguments
+
         if name is None:
             global uid_counter # pylint: disable=global-statement
             uid_counter += 1
@@ -104,19 +107,40 @@ class DjangoDash:
         add_usable_app(self._uid,
                        self)
 
+        if serve_locally is None:
+            self._serve_locally = serve_locally_setting()
+        else:
+            self._serve_locally = serve_locally
+
         self._expanded_callbacks = expanded_callbacks
-        self._serve_locally = serve_locally
         self._suppress_callback_exceptions = suppress_callback_exceptions
 
         if add_bootstrap_links:
             from bootstrap4.bootstrap import css_url
             bootstrap_source = css_url()['href']
-            self.css.append_script({'external_url':[bootstrap_source,]})
+
+            if self._serve_locally:
+                # Ensure package is loaded; if not present then pip install dpd-static-support
+                import dpd_static_support
+                hard_coded_package_name = "dpd_static_support"
+                base_file_name = bootstrap_source.split('/')[-1]
+
+                self.css.append_script({'external_url':        [bootstrap_source,],
+                                        'relative_package_path' : base_file_name,
+                                        'namespace':              hard_coded_package_name,
+                                        })
+            else:
+                self.css.append_script({'external_url':[bootstrap_source,],})
 
         # Remember some caller info for static files
         caller_frame = inspect.stack()[1]
         self.caller_module = inspect.getmodule(caller_frame[0])
         self.caller_module_location = inspect.getfile(self.caller_module)
+        self.assets_folder = "assets"
+
+    def get_asset_static_url(self, asset_path):
+        module_name = self.caller_module.__name__
+        return static_asset_path(module_name, asset_path)
 
     def as_dash_instance(self, cache_id=None):
         '''
@@ -205,6 +229,16 @@ class DjangoDash:
         self._expanded_callbacks = True
         return self.callback(output, inputs, state, events)
 
+    def get_asset_url(self, asset_name):
+        '''URL of an asset associated with this component
+
+        Use a placeholder and insert later
+        '''
+
+        return "assets/" + str(asset_name)
+
+        #return self.as_dash_instance().get_asset_url(asset_name)
+
 class PseudoFlask:
     'Dummy implementation of a Flask instance, providing stub functionality'
     def __init__(self):
@@ -234,7 +268,8 @@ class WrappedDash(Dash):
     # pylint: disable=too-many-arguments, too-many-instance-attributes
     def __init__(self,
                  base_pathname=None, replacements=None, ndid=None,
-                 expanded_callbacks=False, serve_locally=False, **kwargs):
+                 expanded_callbacks=False, serve_locally=False,
+                 **kwargs):
 
         self._uid = ndid
 
@@ -245,7 +280,8 @@ class WrappedDash(Dash):
         kwargs['url_base_pathname'] = self._base_pathname
         kwargs['server'] = self._notflask
 
-        super(WrappedDash, self).__init__(**kwargs)
+        super(WrappedDash, self).__init__(__name__,
+                                          **kwargs)
 
         self.css.config.serve_locally = serve_locally
         self.scripts.config.serve_locally = serve_locally
@@ -507,6 +543,8 @@ class WrappedDash(Dash):
 
     def interpolate_index(self, **kwargs): #pylint: disable=arguments-differ
 
+        print("IN INTERPOLATE INDEX")
+
         if not self._return_embedded:
             resp = super(WrappedDash, self).interpolate_index(**kwargs)
             return resp
diff --git a/django_plotly_dash/finders.py b/django_plotly_dash/finders.py
index d7b9457..d9a5cb4 100644
--- a/django_plotly_dash/finders.py
+++ b/django_plotly_dash/finders.py
@@ -33,9 +33,10 @@ from django.contrib.staticfiles.utils import get_files
 from django.core.files.storage import FileSystemStorage
 
 from django.conf import settings
-from django.apps import apps #pylint: disable=unused-import
+from django.apps import apps
 
 from django_plotly_dash.dash_wrapper import all_apps
+from django_plotly_dash.util import full_asset_path
 
 class DashComponentFinder(BaseFinder):
     'Find static files in components'
@@ -104,9 +105,45 @@ class DashComponentFinder(BaseFinder):
         for component_name in self.locations:
             storage = self.storages[component_name]
             for path in get_files(storage, ignore_patterns + self.ignore_patterns):
-                print("DashAssetFinder", path, storage)
                 yield path, storage
 
+class DashAppDirectoryFinder(BaseFinder):
+    'Find static fies in application subdirectories'
+
+    def __init__(self):
+        # get all registered apps
+
+        self.locations = []
+        self.storages = OrderedDict()
+
+        self.ignore_patterns = ["*.py", "*.pyc",]
+
+        for app_config in apps.get_app_configs():
+
+            path_directory = os.path.join(app_config.path, 'assets')
+
+            if os.path.isdir(path_directory):
+
+                storage = FileSystemStorage(location=path_directory)
+
+                storage.prefix = full_asset_path(app_config.name, "")
+
+                self.locations.append(app_config.name)
+                self.storages[app_config.name] = storage
+
+        super(DashAppDirectoryFinder, self).__init__()
+
+    #pylint: disable=redefined-builtin
+    def find(self, path, all=False):
+        return []
+
+    def list(self, ignore_patterns):
+        for component_name in self.locations:
+            storage = self.storages[component_name]
+            for path in get_files(storage, ignore_patterns + self.ignore_patterns):
+                yield path, storage
+
+
 class DashAssetFinder(BaseFinder):
     'Find static files in asset directories'
 
@@ -114,27 +151,34 @@ class DashAssetFinder(BaseFinder):
 
     def __init__(self):
 
-        # Get all registered apps
+        # Ensure urls are loaded
+        root_urls = settings.ROOT_URLCONF
+        importlib.import_module(root_urls)
 
-        self.apps = all_apps()
+        # Get all registered django dash apps
 
-        self.subdir = 'assets'
+        self.apps = all_apps()
 
         self.locations = []
         self.storages = OrderedDict()
 
         self.ignore_patterns = ["*.py", "*.pyc",]
 
+        added_locations = {}
+
         for app_slug, obj in self.apps.items():
+
             caller_module = obj.caller_module
             location = obj.caller_module_location
-            path_directory = os.path.join(os.path.dirname(location), self.subdir)
+            subdir = obj.assets_folder
+
+            path_directory = os.path.join(os.path.dirname(location), subdir)
 
             if os.path.isdir(path_directory):
 
                 component_name = app_slug
                 storage = FileSystemStorage(location=path_directory)
-                path = "dash/assets/%s" % component_name
+                path = full_asset_path(obj.caller_module.__name__,"")
                 storage.prefix = path
 
                 self.locations.append(component_name)
@@ -151,3 +195,4 @@ class DashAssetFinder(BaseFinder):
             storage = self.storages[component_name]
             for path in get_files(storage, ignore_patterns + self.ignore_patterns):
                 yield path, storage
+
diff --git a/django_plotly_dash/middleware.py b/django_plotly_dash/middleware.py
index 89000ff..44feb8b 100644
--- a/django_plotly_dash/middleware.py
+++ b/django_plotly_dash/middleware.py
@@ -25,6 +25,8 @@ SOFTWARE.
 
 '''
 
+from .util import serve_locally
+
 #pylint: disable=too-few-public-methods
 
 class EmbeddedHolder:
@@ -98,3 +100,44 @@ class BaseMiddleware:
         response = request.dpd_content_handler.adjust_response(response)
 
         return response
+
+
+# Bootstrap4 substitutions, if available
+try:
+    from dpd_static_support.mappings import substitutions as dpd_ss_substitutions
+    substitutions += dpd_ss_substitutions
+except Exception as e:
+    pass
+
+
+class ExternalRedirectionMiddleware:
+    'Middleware to force redirection in third-party content through rewriting'
+
+    def __init__(self, get_response):
+        self.get_response = get_response
+
+        substitutions = []
+
+        if serve_locally():
+            substitutions += dpd_ss_substitutions
+
+        self._encoding = "utf-8"
+
+        self.substitutions = [(self._encode(source),
+                               self._encode(target)) for source, target in substitutions]
+
+    def __call__(self, request):
+
+        response = self.get_response(request)
+
+        content = response.content
+
+        for source, target in self.substitutions:
+            content = content.replace(source, target)
+
+        response.content = content
+        return response
+
+    def _encode(self, string):
+        return string.encode(self._encoding)
+
diff --git a/django_plotly_dash/tests.py b/django_plotly_dash/tests.py
index f63f1fb..eb67234 100644
--- a/django_plotly_dash/tests.py
+++ b/django_plotly_dash/tests.py
@@ -67,6 +67,14 @@ def test_demo_routing():
     assert pipe_ws_endpoint_name() == 'ws/channel'
     assert insert_demo_migrations()
 
+def test_local_serving(settings):
+    'Test local serve settings'
+
+    from django_plotly_dash.util import serve_locally, static_asset_root, full_asset_path
+    assert serve_locally() == settings.DEBUG
+    assert static_asset_root() == 'dpd/assets'
+    assert full_asset_path('fred.jim', 'harry') == 'dpd/assets/fred/jim/harry'
+
 @pytest.mark.django_db
 def test_direct_access(client):
     'Check direct use of a stateless application using demo test data'
@@ -244,3 +252,32 @@ def test_argument_settings(settings, client):
     assert get_initial_arguments(None, None) is None
     assert store_initial_arguments(client, None) is None
     assert get_initial_arguments(client, None) is None
+
+def test_middleware_artifacts():
+    'Import and vaguely exercise middleware objects'
+
+    from django_plotly_dash.middleware import EmbeddedHolder, ContentCollector
+
+    eh = EmbeddedHolder()
+    eh.add_css("some_css")
+    eh.add_config("some_config")
+    eh.add_scripts("some_scripts")
+
+    assert eh.config == 'some_config'
+
+    cc = ContentCollector()
+
+    assert cc._encode("fred") == b'fred'
+
+def test_finders():
+    'Import and vaguely exercise staticfiles finders'
+
+    from django_plotly_dash.finders import DashComponentFinder, DashAppDirectoryFinder, DashAssetFinder
+
+    dcf = DashComponentFinder()
+    dadf = DashAppDirectoryFinder()
+    daf = DashAssetFinder()
+
+    assert dcf is not None
+    assert dadf is not None
+    assert daf is not None
diff --git a/django_plotly_dash/urls.py b/django_plotly_dash/urls.py
index 9bf179f..7e2a10e 100644
--- a/django_plotly_dash/urls.py
+++ b/django_plotly_dash/urls.py
@@ -27,7 +27,7 @@ SOFTWARE.
 from django.urls import path
 from django.views.decorators.csrf import csrf_exempt
 
-from .views import routes, layout, dependencies, update, main_view, component_suites, component_component_suites
+from .views import routes, layout, dependencies, update, main_view, component_suites, component_component_suites, asset_redirection
 
 from .app_name import app_name, main_view_label
 
@@ -48,6 +48,7 @@ for base_type, args, name_prefix, url_ending, name_suffix in [('instance', {}, '
                                                       ('', main_view, main_view_label, '', ),
                                                       ('_dash-component-suites', component_suites, 'component-suites', '/<slug:component>/<resource>', ),
                                                       ('_dash-component-suites', component_component_suites, 'component-component-suites', '/<slug:component>/_components/<resource>', ),
+                                                      ('assets', asset_redirection, 'asset-redirect', '/<path:path>', ),
                                                      ]:
 
         route_name = '%s%s%s' % (name_prefix, name, name_suffix)
diff --git a/django_plotly_dash/util.py b/django_plotly_dash/util.py
index d180bd1..15fb9d7 100644
--- a/django_plotly_dash/util.py
+++ b/django_plotly_dash/util.py
@@ -27,6 +27,7 @@ import uuid
 
 from django.conf import settings
 from django.core.cache import cache
+from django.contrib.staticfiles.templatetags.staticfiles import static
 
 def _get_settings():
     try:
@@ -95,3 +96,18 @@ def get_initial_arguments(request, cache_id=None):
         return cache.get(cache_id)
 
     return request.session[cache_id]
+
+def static_asset_root():
+    return _get_settings().get('static_asset_root','dpd/assets')
+
+def full_asset_path(module_name, asset_path):
+    path_contrib = "%s/%s/%s" %(static_asset_root(),
+                                "/".join(module_name.split(".")),
+                                asset_path)
+    return path_contrib
+
+def static_asset_path(module_name, asset_path):
+    return static(full_asset_path(module_name, asset_path))
+
+def serve_locally():
+    return _get_settings().get('serve_locally', settings.DEBUG)
diff --git a/django_plotly_dash/views.py b/django_plotly_dash/views.py
index 814eed7..27148e1 100644
--- a/django_plotly_dash/views.py
+++ b/django_plotly_dash/views.py
@@ -27,6 +27,7 @@ SOFTWARE.
 import json
 
 from django.http import HttpResponse, HttpResponseRedirect
+from django.shortcuts import redirect
 
 from .models import DashApp
 from .util import get_initial_arguments
@@ -117,8 +118,6 @@ def component_suites(request, resource=None, component=None, extra_element="", *
     else:
         redone_url = "/static/dash/component/%s/%s%s" %(component, extra_element, resource)
 
-    print("Redirecting to :", redone_url)
-
     return HttpResponseRedirect(redirect_to=redone_url)
 
 def app_assets(request, **kwargs):
@@ -145,3 +144,14 @@ def add_to_session(request, template_name="index.html", **kwargs):
     request.session['django_plotly_dash'] = django_plotly_dash
 
     return TemplateResponse(request, template_name, {})
+
+def asset_redirection(request, path, ident=None, stateless=False, **kwargs):
+    'Redirect static assets for a component'
+
+    X, app = DashApp.locate_item(ident, stateless)
+
+    # Redirect to a location based on the import path of the module containing the DjangoDash app
+    static_path = X.get_asset_static_url(path)
+
+    return redirect(static_path)
+
diff --git a/docs/configuration.rst b/docs/configuration.rst
index 3883afe..b1b01f8 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -30,6 +30,9 @@ below.
 
       # Flag to control location of initial argument storage
       "cache_arguments": True,
+
+      # Flag controlling local serving of assets
+      "serve_locally': settings.DEBUG,
   }
 
 Defaults are inserted for missing values. It is also permissible to not have any ``PLOTLY_DASH`` entry in
@@ -44,10 +47,13 @@ file finders
   # Staticfiles finders for locating dash app assets and related files
 
   STATICFILES_FINDERS = [
+
       'django.contrib.staticfiles.finders.FileSystemFinder',
       'django.contrib.staticfiles.finders.AppDirectoriesFinder',
+
       'django_plotly_dash.finders.DashAssetFinder',
       'django_plotly_dash.finders.DashComponentFinder',
+      'django_plotly_dash.finders.DashAppDirectoryFinder',
   ]
 
 and also providing a list of components used
@@ -74,6 +80,41 @@ and also providing a list of components used
 This list should be extended with any additional components that the applications
 use, where the components have files that have to be served locally.
 
+Furthermore, middleware should be added for redirection of external assets from
+underlying packages, such as ``dash-bootstrap-components``. With the standard
+Django middleware, along with ``whitenoise``, the entry within the ``settings.py``
+file will look something like
+
+.. code-block:: python
+
+  # Standard Django middleware with the addition of both
+  # whitenoise and django_plotly_dash items
+
+  MIDDLEWARE = [
+
+        'django.middleware.security.SecurityMiddleware',
+
+        'whitenoise.middleware.WhiteNoiseMiddleware',
+
+        'django.contrib.sessions.middleware.SessionMiddleware',
+        'django.middleware.common.CommonMiddleware',
+        'django.middleware.csrf.CsrfViewMiddleware',
+        'django.contrib.auth.middleware.AuthenticationMiddleware',
+        'django.contrib.messages.middleware.MessageMiddleware',
+
+        'django_plotly_dash.middleware.BaseMiddleware',
+        'django_plotly_dash.middleware.ExternalRedirectionMiddleware',
+
+        'django.middleware.clickjacking.XFrameOptionsMiddleware',
+    ]
+
+
+Individual apps can set their ``serve_locally`` flag. However, it is recommended to use
+the equivalent global ``PLOTLY_DASH`` setting to provide a common approach for all
+static assets. See :ref:`local_assets` for more information on how local assets are configured
+and served as part of the standard Django staticfiles approach, along with details on the
+integration of other components and some known issues.
+
 .. _endpoints:
 
 Endpoints
diff --git a/docs/faq.rst b/docs/faq.rst
index c6afa2f..7a2fcc1 100644
--- a/docs/faq.rst
+++ b/docs/faq.rst
@@ -32,3 +32,28 @@ in this github `issue <https://github.com/GibbsConsulting/django-plotly-dash/iss
 
  Yes. See the :ref:`view_decoration` configuration setting and :ref:`access_control` section.
 
+* What settings are needed to run the server in debug mode?
+
+The ``prepare_demo`` script in the root of the git repository contains the full set of commands
+for running the server in debug mode. In particular, the debug server is launched with the ``--nostatic`` option. This
+will cause the staticfiles to be served from the collected files in the ``STATIC_ROOT`` location rather than the normal
+``runserver`` behaviour of serving directly from the various
+locations in the ``STATICFILES_DIRS`` list.
+
+* Is use of the ``get_asset_url`` function optional for including static assets?
+
+No, it is needed. Consider this example (it is part of ``demo-nine``):
+
+.. code-block:: python
+
+  localState = DjangoDash("LocalState",
+                          serve_locally=True)
+
+  localState.layout = html.Div([html.Img(src=localState.get_asset_url('image_one.png')),
+                                html.Img(src='/assets/image_two.png'),
+                                ])
+
+The first ``Img`` will have its source file correctly served up by Django as a standard static file. However, the second image will
+not be rendered as the path will be incorrect.
+
+See the :ref:`local_assets` section for more information on `configuration` with local assets.
diff --git a/docs/index.rst b/docs/index.rst
index 5776165..cb77a7d 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -23,6 +23,7 @@ Contents
    template_tags
    dash_components
    configuration
+   local_assets
    demo_notes
    access_control
    faq
diff --git a/docs/installation.rst b/docs/installation.rst
index cf4d554..2a75a19 100644
--- a/docs/installation.rst
+++ b/docs/installation.rst
@@ -17,12 +17,17 @@ Then, add ``django_plotly_dash`` to ``INSTALLED_APPS`` in the Django ``settings.
         ...
         ]
 
-Further, if the :ref:`header and footer <plotly_header_footer>` tags are in use
-then ``django_plotly_dash.middleware.BaseMiddleware`` should be added to ``MIDDLEWARE`` in the same file. This can be safely added now even if not used.
-
 The project directory name ``django_plotly_dash`` can also be used on its own if preferred, but this will stop the use of readable application names in
 the Django admin interface.
 
+Further, if the :ref:`header and footer <plotly_header_footer>` tags are in use
+then ``django_plotly_dash.middleware.BaseMiddleware`` should be added to ``MIDDLEWARE`` in the same file. This
+can be safely added now even if not used.
+
+If assets are being served locally through the use of the global ``serve_locally`` or on a per-app basis, then
+``django_plotly_dash.middleware.ExternalRedirectionMiddleware`` should be added, along with the ``whitenoise`` package whose
+middleware should also be added as per the instructions for that package.
+
 The application's routes need to be registered within the routing structure by an appropriate ``include`` statement in
 a ``urls.py`` file::
 
diff --git a/docs/local_assets.rst b/docs/local_assets.rst
new file mode 100644
index 0000000..137f0c2
--- /dev/null
+++ b/docs/local_assets.rst
@@ -0,0 +1,65 @@
+.. _local_assets:
+
+Local assets
+============
+
+Local ploty dash assets are integrated into the standard Django staticfiles structure. This requires additional
+settings for both staticfiles finders and middleware, and also providing a list of the components used. The
+specific steps are listed in the :ref:`configuration` section.
+
+Individual applications can set a ``serve_locally`` flag but the use of the global setting in the ``PLOTLY_DASH``
+variable is recommended.
+
+Additional components
+---------------------
+
+Some components, such as ``dash-bootstrap-components``, require external packages such as Bootstrap to be supplied. In
+turn this can be achieved using for example the ``bootstrap4`` Django application. As a consequence, dependencies on
+external URLs are introduced.
+
+This can be avoided by use of the ``dpd-static-support`` package, which supplies mappings to locally served versions of
+these assets. Installation is through the standard ``pip`` approach
+
+.. code-block:: bash
+
+   pip install dpd-static-support
+
+and then the package should be added as both an installed app and to the ``PLOTLY_COMPONENTS`` list
+in ``settings.py``, along with the associated middleware
+
+.. code-block:: python
+
+    INSTALLED_APPS = [
+        ...
+        'dpd_static_support',
+    ]
+
+    MIDDLEWARE = [
+        ...
+        'django_plotly_dash.middleware.ExternalRedirectionMiddleware',
+    ]
+
+    PLOTLY_COMPONENTS = [
+        ...
+        'dpd_static_support'
+    ]
+
+Note that the middleware can be safely added even if the ``serve_locally`` functionality is not in use.
+
+Known issues
+------------
+
+Absolute paths to assets will not work correctly. For example:
+
+.. code-block:: python
+
+    app.layout = html.Div([html.Img(src=localState.get_asset_url('image_one.png')),
+                           html.Img(src='assets/image_two.png'),
+                           html.Img(src='/assets/image_three.png'),
+                           ])
+
+Of these three images, both ``image_one.png`` and ``image_two.png`` will be served up - through the static files
+infrastructure - from the ``assets`` subdirectory relative to the code defining the ``app`` object. However, when
+rendered the application will attempt to load ``image_three.png`` using an absolute path. This is unlikely to
+be the desired result, but does permit the use of absolute URLs within the server.
+
diff --git a/prepare_demo b/prepare_demo
index 5023168..0eec48b 100755
--- a/prepare_demo
+++ b/prepare_demo
@@ -5,4 +5,7 @@ cd demo
 ./manage.py migrate
 ./manage.py shell < configdb.py # Add a superuser if needed
 ./manage.py collectstatic -i "*.py" -i "*.pyc" --noinput --link
-./manage.py runserver
+#
+# Run debug server. Use the nostatic flag to enable the use of whitenose rather than the standard Django debug handling
+#
+./manage.py runserver --nostatic
diff --git a/requirements.txt b/requirements.txt
index 3783c07..baf08da 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,11 +1,11 @@
-dash
-dash-core-components
-dash-html-components
-dash-renderer
+dash==0.38
+dash-core-components==0.43.1
+dash-html-components==0.13.5
+dash-renderer==0.19
 plotly
 dpd-components
 
 dash-bootstrap-components
 
-Django>=2
+Django>=2,<2.2
 Flask>=1.0.2
-- 
GitLab