diff --git a/django_plotly_dash/__init__.py b/django_plotly_dash/__init__.py index 36a143b7..ba2ec4d3 100644 --- a/django_plotly_dash/__init__.py +++ b/django_plotly_dash/__init__.py @@ -1,6 +1,6 @@ # -__version__ = "0.2.0" +__version__ = "0.3.0" from .dash_wrapper import DjangoDash diff --git a/django_plotly_dash/admin.py b/django_plotly_dash/admin.py index 4580ac5b..491f1515 100644 --- a/django_plotly_dash/admin.py +++ b/django_plotly_dash/admin.py @@ -1,6 +1,9 @@ from django.contrib import admin -from .models import DashApp, DashAppAdmin +from .models import (DashApp, DashAppAdmin, + StatelessApp, StatelessAppAdmin, + ) admin.site.register(DashApp, DashAppAdmin) +admin.site.register(StatelessApp, StatelessAppAdmin) diff --git a/django_plotly_dash/dash_wrapper.py b/django_plotly_dash/dash_wrapper.py index b1c4af39..0a0d132e 100644 --- a/django_plotly_dash/dash_wrapper.py +++ b/django_plotly_dash/dash_wrapper.py @@ -21,7 +21,7 @@ def add_usable_app(name, app): usable_apps[name] = app return name -def get_stateless_by_name(name): +def get_local_stateless_by_name(name): ''' Locate a registered dash app by name, and return a DjangoDash instance encapsulating the app. ''' @@ -60,7 +60,7 @@ def as_dash_instance(self): ''' Form a dash instance, for stateless use of this app ''' - return self.form_dash_instance() + return self.do_form_dash_instance() def handle_current_state(self): 'Do nothing impl - only matters if state present' @@ -80,21 +80,23 @@ def get_base_pathname(self, specific_identifier): app_pathname="%s:%s" % (app_name, main_view_label) ndid = specific_identifier - try: - full_url = reverse(app_pathname,kwargs={'id':ndid}) - except: - full_url = "/%s/" %ndid - + full_url = reverse(app_pathname,kwargs={'id':ndid}) return ndid, full_url - def form_dash_instance(self, replacements=None, specific_identifier=None): + def do_form_dash_instance(self, replacements=None, specific_identifier=None): ndid, base_pathname = self.get_base_pathname(specific_identifier) + return self.form_dash_instance(replacements, ndid, base_pathname) + + def form_dash_instance(self, replacements=None, ndid=None, base_pathname=None): + + if ndid is None: + ndid = self._uid - rd = NotDash(base_pathname=base_pathname, - expanded_callbacks = self._expanded_callbacks, - replacements = replacements, - ndid = ndid) + rd = WrappedDash(base_pathname=base_pathname, + expanded_callbacks = self._expanded_callbacks, + replacements = replacements, + ndid = ndid) rd.layout = self.layout @@ -121,7 +123,7 @@ def expanded_callback(self, output, inputs=[], state=[], events=[]): self._expanded_callbacks = True return self.callback(output, inputs, state, events) -class NotFlask: +class PseudoFlask: def __init__(self): self.config = {} self.endpoints = {} @@ -138,22 +140,22 @@ def before_first_request(self,*args,**kwargs): def run(self,*args,**kwargs): pass -class NotDash(Dash): +class WrappedDash(Dash): def __init__(self, base_pathname=None, replacements = None, ndid=None, expanded_callbacks=False, **kwargs): self._uid = ndid self._flask_app = Flask(self._uid) - self._notflask = NotFlask() + self._notflask = PseudoFlask() self._base_pathname = base_pathname kwargs['url_base_pathname'] = self._base_pathname kwargs['server'] = self._notflask - super(NotDash, self).__init__(**kwargs) + super(WrappedDash, self).__init__(**kwargs) self.css.config.serve_locally = True - #self.css.config.serve_locally = False + self.css.config.serve_locally = False self.scripts.config.serve_locally = self.css.config.serve_locally @@ -273,10 +275,10 @@ def _fix_callback_item(self, item): return item def callback(self, output, inputs=[], state=[], events=[]): - return super(NotDash, self).callback(self._fix_callback_item(output), - [self._fix_callback_item(x) for x in inputs], - [self._fix_callback_item(x) for x in state], - [self._fix_callback_item(x) for x in events]) + return super(WrappedDash, self).callback(self._fix_callback_item(output), + [self._fix_callback_item(x) for x in inputs], + [self._fix_callback_item(x) for x in state], + [self._fix_callback_item(x) for x in events]) def dispatch(self): import flask diff --git a/django_plotly_dash/migrations/0001_initial.py b/django_plotly_dash/migrations/0001_initial.py index 8cb034e5..f4016aea 100644 --- a/django_plotly_dash/migrations/0001_initial.py +++ b/django_plotly_dash/migrations/0001_initial.py @@ -1,6 +1,7 @@ -# Generated by Django 2.0.5 on 2018-05-10 21:48 +# Generated by Django 2.0.5 on 2018-06-08 20:02 from django.db import migrations, models +import django.db.models.deletion class Migration(migrations.Migration): @@ -15,12 +16,25 @@ class Migration(migrations.Migration): 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()), + ('base_state', models.TextField(default='{}')), ('creation', models.DateTimeField(auto_now_add=True)), ('update', models.DateTimeField(auto_now=True)), + ('save_on_change', models.BooleanField(default=False)), ], ), + migrations.CreateModel( + name='StatelessApp', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('app_name', models.CharField(max_length=100, unique=True)), + ('slug', models.SlugField(blank=True, max_length=110, unique=True)), + ], + ), + migrations.AddField( + model_name='dashapp', + name='stateless_app', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='django_plotly_dash.StatelessApp'), + ), ] diff --git a/django_plotly_dash/migrations/0002_add_examples.py b/django_plotly_dash/migrations/0002_add_examples.py new file mode 100644 index 00000000..2a97dcc4 --- /dev/null +++ b/django_plotly_dash/migrations/0002_add_examples.py @@ -0,0 +1,36 @@ +# Generated by Django 2.0.5 on 2018-06-08 20:03 + +from django.db import migrations + +def addExamples(apps, schema_editor): + + DashApp = apps.get_model("django_plotly_dash","DashApp") + StatelessApp = apps.get_model("django_plotly_dash","StatelessApp") + + sa1 = StatelessApp(app_name="SimpleExample", + slug="simple-example") + + sa1.save() + + da1 = DashApp(stateless_app=sa1, + instance_name="SimpleExample-1", + slug="simpleexample-1", + base_state='{"dropdown-color":{"value":"blue"},"dropdown-size":{"value":"small"}}') + + da1.save() + + +def remExamples(apps, schema_editor): + + DashApp.objects.all().delete() + StatelessApp.objects.all().delete() + +class Migration(migrations.Migration): + + dependencies = [ + ('django_plotly_dash', '0001_initial'), + ] + + operations = [ + migrations.RunPython(addExamples, remExamples), + ] diff --git a/django_plotly_dash/migrations/0002_simple_example_state.py b/django_plotly_dash/migrations/0002_simple_example_state.py deleted file mode 100644 index c6cef1e3..00000000 --- a/django_plotly_dash/migrations/0002_simple_example_state.py +++ /dev/null @@ -1,28 +0,0 @@ -# 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/django_plotly_dash/migrations/0003_auto_20180514_1802.py b/django_plotly_dash/migrations/0003_auto_20180514_1802.py deleted file mode 100644 index b383c0be..00000000 --- a/django_plotly_dash/migrations/0003_auto_20180514_1802.py +++ /dev/null @@ -1,23 +0,0 @@ -# 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 dd6403ae..0a183496 100644 --- a/django_plotly_dash/models.py +++ b/django_plotly_dash/models.py @@ -3,15 +3,59 @@ from django.utils.text import slugify from django.shortcuts import get_object_or_404 -from .dash_wrapper import get_stateless_by_name +from .dash_wrapper import get_local_stateless_by_name import json +def get_stateless_by_name(name): + return get_local_stateless_by_name(name) + +class StatelessApp(models.Model): + ''' + A stateless Dash app. An instance of this model represents a dash app without any specific state + ''' + app_name = models.CharField(max_length=100, blank=False, null=False, unique=True) + slug = models.SlugField(max_length=110, unique=True, blank=True) + + def __str__(self): + return self.app_name + + def save(self, *args, **kwargs): + if not self.slug or len(self.slug) < 2: + self.slug = slugify(self.app_name) + return super(StatelessApp, self).save(*args,**kwargs) + + def as_dash_app(self): + ''' + Return a DjangoDash instance of the dash application + ''' + 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 find_stateless_by_name(name): + try: + dsa = StatelessApp.objects.get(app_name=name) + return dsa.as_dash_app() + except: + pass + + da = get_stateless_by_name(name) + dsa = StatelessApp(app_name=name) + dsa.save() + return da + +class StatelessAppAdmin(admin.ModelAdmin): + list_display = ['app_name','slug',] + list_filter = ['app_name','slug',] + 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) + stateless_app = models.ForeignKey(StatelessApp, on_delete=models.PROTECT, unique=False, null=False, blank=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="{}") # If mandating postgresql then this could be a JSONField @@ -24,19 +68,12 @@ def __str__(self): 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) + existing_count = DashApp.objects.all().count() + self.instance_name = "%s-%i" %(self.stateless_app.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) - def _stateless_dash_app(self): - 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 handle_current_state(self): ''' Check to see if the current hydrated state and the saved state are different. @@ -82,16 +119,16 @@ def current_state(self): return cs def as_dash_instance(self): - dd = self._stateless_dash_app() + dd = self.stateless_app.as_dash_app() base = self.current_state() - return dd.form_dash_instance(replacements=base, - specific_identifier=self.slug) + return dd.do_form_dash_instance(replacements=base, + specific_identifier=self.slug) 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().as_dash_instance() + base_app_inst = self.stateless_app.as_dash_app().as_dash_instance() # Get base layout response, from a base object base_resp = base_app_inst.locate_endpoint_function('dash-layout')() @@ -113,7 +150,7 @@ def populate_values(self): @staticmethod def locate_item(id, stateless=False): if stateless: - da = get_stateless_by_name(id) + da = find_stateless_by_name(id) else: da = get_object_or_404(DashApp,slug=id) @@ -121,13 +158,22 @@ def locate_item(id, stateless=False): return da, app class DashAppAdmin(admin.ModelAdmin): - list_display = ['instance_name','app_name','slug','creation','update','save_on_change',] - list_filter = ['creation','update','save_on_change','app_name',] + list_display = ['instance_name','stateless_app','slug','creation','update','save_on_change',] + list_filter = ['creation','update','save_on_change','stateless_app',] def _populate_values(self, request, queryset): for da in queryset: da.populate_values() da.save() - _populate_values.short_description = "Populate app" + _populate_values.short_description = "Populate app instance" + + def _clone(self, request, queryset): + for da in queryset: + nda = DashApp(stateless_app=da.stateless_app, + base_state=da.base_state, + save_on_change=da.save_on_change) + nda.save() + + _clone.short_description = "Clone app instance" - actions = ['_populate_values',] + actions = ['_populate_values','_clone',] diff --git a/django_plotly_dash/templatetags/plotly_dash.py b/django_plotly_dash/templatetags/plotly_dash.py index 10ba444e..6b0773f8 100644 --- a/django_plotly_dash/templatetags/plotly_dash.py +++ b/django_plotly_dash/templatetags/plotly_dash.py @@ -4,7 +4,6 @@ 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_app(context, name=None, slug=None, da=None, ratio=0.1, use_frameborder=False): @@ -26,12 +25,16 @@ def plotly_app(context, name=None, slug=None, da=None, ratio=0.1, use_frameborde height: 100%; """ + app = None + if name is not None: - da = get_stateless_by_name(name) + da, app = DashApp.locate_item(name, stateless=True) if slug is not None: - da = get_object_or_404(DashApp,slug=slug) + da, app = DashApp.locate_item(slug, stateless=False) + + if not app: + app = da.as_dash_instance() - app = da.as_dash_instance() return locals() diff --git a/do_the_build b/do_the_build new file mode 100644 index 00000000..21a42a93 --- /dev/null +++ b/do_the_build @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# +source env/bin/activate +python setup.py sdist +python setup.py bdist_wheel + +#twine upload dist/* +#cd docs && sphinx-autobuild . _build/html +#grip + diff --git a/docs/models_and_state.rst b/docs/models_and_state.rst index 78474ef1..7c5b782a 100644 --- a/docs/models_and_state.rst +++ b/docs/models_and_state.rst @@ -1,9 +1,43 @@ .. _models_and_state: Django models and application state -================ +=================================== -The ``django_plotly_dash`` application defines a ``DashApp`` model. This represents an instance of application state. +The ``django_plotly_dash`` application defines ``DashApp`` and ``StatelessApp`` models. + +The ``StatelessApp`` model +---------------------- + +An instance of the ``StatelessApp`` model represents a single dash application. Every instantiation of +a ``DjangoDash`` object is registered, and any object that is referenced through the ``DashApp`` model - this +includes all template access as well as model instances themselves - causes a ``StatelessApp`` model instance to +be created if one does not already exist. + +.. code-block:: python + + class StatelessApp(models.Model): + ''' + A stateless Dash app. + + An instance of this model represents a dash app without any specific state + ''' + + app_name = models.CharField(max_length=100, blank=False, null=False, unique=True) + slug = models.SlugField(max_length=110, unique=True, blank=True) + + def as_dash_app(self): + ''' + Return a DjangoDash instance of the dash application + ''' + +The main role of a ``StatelessApp`` instance is to manage access to the associated ``DjangoDash`` object, as +expsosed through the ``as_dash_app`` member +function. + +The ``DashApp`` model +--------------------- + +An instance of the ``DashApp`` model represents an instance of application state. .. code-block:: python @@ -11,7 +45,8 @@ The ``django_plotly_dash`` application defines a ``DashApp`` model. This represe ''' 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) + stateless_app = models.ForeignKey(StatelessApp, on_delete=models.PROTECT, + unique=False, null=False, blank=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="{}") @@ -36,7 +71,7 @@ The ``django_plotly_dash`` application defines a ``DashApp`` model. This represe 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 +The ``stateless_app`` references an instance of the ``StatelessApp`` model described above. 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 diff --git a/docs/template_tags.rst b/docs/template_tags.rst index 6edaf789..3f1e5aac 100644 --- a/docs/template_tags.rst +++ b/docs/template_tags.rst @@ -15,7 +15,7 @@ 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. +:slug = None: The slug of an existing ``DashApp`` 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. @@ -24,4 +24,3 @@ The tag arguments are: 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. -