Repository: GibbsConsulting/django-plotly-dash

Issue:
Move to newer urls function to avoid Django 4.0 deprecation warning
When running the test suite there are a number of warnings of the form
```
demo/tests/test_dpd_demo.py::test_template_tag_use
  /home/mark/local/django-plotly-dash/demo/demo/urls.py:38: RemovedInDjango40Warning:
```

These can be removed by modifying the code to use the newer functions.


Discussion:

Patch:
diff --git a/CONTRIBUTIONS.md b/CONTRIBUTIONS.md
index c45c689..20eefa6 100644
--- a/CONTRIBUTIONS.md
+++ b/CONTRIBUTIONS.md
@@ -39,3 +39,6 @@ Thanks to the following people:
 [CaseGuide](https://github.com/CaseGuide)
 
 [manish-podugu](https://github.com/manish-podugu)
+
+[sdementen](https://github.com/sdementen)
+
diff --git a/check_code_dpd b/check_code_dpd
index a6483f9..09d12c8 100755
--- a/check_code_dpd
+++ b/check_code_dpd
@@ -2,4 +2,4 @@
 #
 source env/bin/activate
 #
-pylint django_plotly_dash
+pylint django_plotly_dash --rcfile=pylintrc
diff --git a/demo/demo/dash_apps.py b/demo/demo/dash_apps.py
index 9c39910..f85c763 100644
--- a/demo/demo/dash_apps.py
+++ b/demo/demo/dash_apps.py
@@ -53,7 +53,7 @@ dash_example1.layout = html.Div(id='main',
                                     html.Div(id='test-output-div2'),
                                     html.Div(id='test-output-div3')
 
-                                    ]) # end of 'main'
+                                ]) # end of 'main'
 
 @dash_example1.expanded_callback(
     dash.dependencies.Output('test-output-div', 'children'),
@@ -94,9 +94,6 @@ def callback_test(*args, **kwargs): #pylint: disable=unused-argument
 def callback_test2(*args, **kwargs):
     'Callback to exercise session functionality'
 
-    print(args)
-    print(kwargs)
-
     children = [html.Div(["You have selected %s." %(args[0])]),
                 html.Div(["The session context message is '%s'" %(kwargs['session_state']['django_to_dash_context'])])]
 
diff --git a/demo/demo/plotly_apps.py b/demo/demo/plotly_apps.py
index bf429ce..b1ea680 100644
--- a/demo/demo/plotly_apps.py
+++ b/demo/demo/plotly_apps.py
@@ -377,3 +377,37 @@ def multiple_callbacks_two(button_clicks, color_choice, **kwargs):
             "Output 2: %s %s %s" % (button_clicks, color_choice, kwargs['callback_context'].triggered),
             "Output 3: %s %s [%s]" % (button_clicks, color_choice, kwargs)
             ]
+
+
+flexible_expanded_callbacks = DjangoDash("FlexibleExpandedCallbacks")
+
+flexible_expanded_callbacks.layout = html.Div([
+    html.Button("Press Me",
+                id="button"),
+    dcc.RadioItems(id='dropdown-color',
+                   options=[{'label': c, 'value': c.lower()} for c in ['Red', 'Green', 'Blue']],
+                   value='red'
+                   ),
+    html.Div(id="output-one"),
+    html.Div(id="output-two"),
+    html.Div(id="output-three")
+    ])
+
+@flexible_expanded_callbacks.expanded_callback(
+    dash.dependencies.Output('output-one', 'children'),
+    [dash.dependencies.Input('button', 'n_clicks')])
+def exp_callback_kwargs(button_clicks, **kwargs):
+    return str(kwargs)
+
+
+@flexible_expanded_callbacks.expanded_callback(
+    dash.dependencies.Output('output-two', 'children'),
+    [dash.dependencies.Input('button', 'n_clicks')])
+def exp_callback_standard(button_clicks):
+    return "ok"
+
+@flexible_expanded_callbacks.expanded_callback(
+    dash.dependencies.Output('output-three', 'children'),
+    [dash.dependencies.Input('button', 'n_clicks')])
+def exp_callback_dash_app_id(button_clicks, dash_app_id):
+    return dash_app_id
diff --git a/demo/demo/scaffold.py b/demo/demo/scaffold.py
index 4327f7d..02cbccb 100644
--- a/demo/demo/scaffold.py
+++ b/demo/demo/scaffold.py
@@ -3,7 +3,7 @@
 from django_plotly_dash import DjangoDash
 from django.utils.module_loading import import_string
 
-from demo.plotly_apps import multiple_callbacks
+from demo.plotly_apps import multiple_callbacks, flexible_expanded_callbacks
 
 def stateless_app_loader(app_name):
 
diff --git a/demo/demo/urls.py b/demo/demo/urls.py
index 0162d42..13e942a 100644
--- a/demo/demo/urls.py
+++ b/demo/demo/urls.py
@@ -17,7 +17,7 @@ Including another URLconf
 # pylint: disable=wrong-import-position,wrong-import-order
 
 from django.contrib import admin
-from django.urls import include
+from django.urls import include, path
 from django.conf.urls import url
 
 from django.views.generic import TemplateView
@@ -35,21 +35,21 @@ from django_plotly_dash.views import add_to_session
 from .views import dash_example_1_view, session_state_view
 
 urlpatterns = [
-    url('^$', TemplateView.as_view(template_name='index.html'), name="home"),
-    url('^demo-one$', TemplateView.as_view(template_name='demo_one.html'), name="demo-one"),
-    url('^demo-two$', TemplateView.as_view(template_name='demo_two.html'), name="demo-two"),
-    url('^demo-three$', TemplateView.as_view(template_name='demo_three.html'), name="demo-three"),
-    url('^demo-four$', TemplateView.as_view(template_name='demo_four.html'), name="demo-four"),
-    url('^demo-five$', TemplateView.as_view(template_name='demo_five.html'), name="demo-five"),
-    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('^demo-ten', TemplateView.as_view(template_name='demo_ten.html'), name="demo-ten"),
-    url('^admin/', admin.site.urls),
-    url('^django_plotly_dash/', include('django_plotly_dash.urls')),
-
-    url('^demo-session-var$', add_to_session, name="session-variable-example"),
+    path('', TemplateView.as_view(template_name='index.html'), name="home"),
+    path('demo-one', TemplateView.as_view(template_name='demo_one.html'), name="demo-one"),
+    path('demo-two', TemplateView.as_view(template_name='demo_two.html'), name="demo-two"),
+    path('demo-three', TemplateView.as_view(template_name='demo_three.html'), name="demo-three"),
+    path('demo-four', TemplateView.as_view(template_name='demo_four.html'), name="demo-four"),
+    path('demo-five', TemplateView.as_view(template_name='demo_five.html'), name="demo-five"),
+    path('demo-six', dash_example_1_view, name="demo-six"),
+    path('demo-seven', TemplateView.as_view(template_name='demo_seven.html'), name="demo-seven"),
+    path('demo-eight', session_state_view, {'template_name':'demo_eight.html'}, name="demo-eight"),
+    path('demo-nine', TemplateView.as_view(template_name='demo_nine.html'), name="demo-nine"),
+    path('demo-ten', TemplateView.as_view(template_name='demo_ten.html'), name="demo-ten"),
+    path('admin/', admin.site.urls),
+    path('django_plotly_dash/', include('django_plotly_dash.urls')),
+
+    path('demo-session-var', add_to_session, name="session-variable-example"),
 ]
 
 # Add in static routes so daphne can serve files; these should
diff --git a/django_plotly_dash/dash_wrapper.py b/django_plotly_dash/dash_wrapper.py
index 570be8e..06fab44 100644
--- a/django_plotly_dash/dash_wrapper.py
+++ b/django_plotly_dash/dash_wrapper.py
@@ -24,7 +24,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 SOFTWARE.
 '''
-
+import itertools
 import json
 import inspect
 
@@ -160,7 +160,6 @@ class DjangoDash:
     '''
     #pylint: disable=too-many-instance-attributes
     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
@@ -186,7 +185,6 @@ class DjangoDash:
         else:
             self._serve_locally = serve_locally
 
-        self._expanded_callbacks = expanded_callbacks
         self._suppress_callback_exceptions = suppress_callback_exceptions
 
         if add_bootstrap_links:
@@ -268,7 +266,6 @@ class DjangoDash:
             ndid = self._uid
 
         rd = WrappedDash(base_pathname=base_pathname,
-                         expanded_callbacks=self._expanded_callbacks,
                          replacements=replacements,
                          ndid=ndid,
                          serve_locally=self._serve_locally)
@@ -287,26 +284,58 @@ class DjangoDash:
 
         return rd
 
+    @staticmethod
+    def get_expanded_arguments(func, inputs, state):
+        """Analyse a callback function signature to detect the expanded arguments to add when called.
+        It uses the inputs and the state information to identify what arguments are already coming from Dash.
+
+        It returns a list of the expanded parameters to inject (can be [] if nothing should be injected)
+         or None if all parameters should be injected."""
+        n_dash_parameters = len(inputs or []) + len(state or [])
+
+        parameter_types = {kind: [p.name for p in parameters] for kind, parameters in
+                           itertools.groupby(inspect.signature(func).parameters.values(), lambda p: p.kind)}
+        if inspect.Parameter.VAR_KEYWORD in parameter_types:
+            # there is some **kwargs, inject all parameters
+            expanded = None
+        elif inspect.Parameter.VAR_POSITIONAL in parameter_types:
+            # there is a *args, assume all parameters afterwards (KEYWORD_ONLY) are to be injected
+            # some of these parameters may not be expanded arguments but that is ok
+            expanded = parameter_types.get(inspect.Parameter.KEYWORD_ONLY, [])
+        else:
+            # there is no **kwargs, filter argMap to take only the keyword arguments
+            expanded = parameter_types.get(inspect.Parameter.POSITIONAL_OR_KEYWORD, [])[
+                       n_dash_parameters:] + parameter_types.get(inspect.Parameter.KEYWORD_ONLY, [])
+
+        return expanded
+
     def callback(self, output, inputs=None, state=None, events=None):
-        'Form a callback function by wrapping, in the same way as the underlying Dash application would'
-        callback_set = {'output':output,
-                        'inputs':inputs and inputs or dict(),
-                        'state':state and state or dict(),
-                        'events':events and events or dict()}
-        def wrap_func(func, callback_set=callback_set, callback_sets=self._callback_sets): # pylint: disable=dangerous-default-value, missing-docstring
-            callback_sets.append((callback_set, func))
-            return func
-        return wrap_func
+        '''Form a callback function by wrapping, in the same way as the underlying Dash application would
+        but handling extra arguments provided by dpd.
 
-    def expanded_callback(self, output, inputs=[], state=[], events=[]): # pylint: disable=dangerous-default-value
-        '''
-        Form an expanded callback.
+        It will inspect the signature of the function to ensure only relevant expanded arguments are passed to the callback.
+
+        If the function accepts a **kwargs => all expanded arguments are sent to the function in the kwargs.
+        If the function has a *args => expanded arguments matching parameters after the *args are injected.
+        Otherwise, take all arguments beyond the one provided by Dash (based on the Inputs/States provided).
 
-        This function registers the callback function, and sets an internal flag that mandates that all
-        callbacks are passed the enhanced arguments.
         '''
-        self._expanded_callbacks = True
-        return self.callback(output, inputs, state, events)
+        callback_set = {'output': output,
+                        'inputs': inputs or [],
+                        'state': state or [],
+                        'events': events or []}
+
+        def wrap_func(func):
+            self._callback_sets.append((callback_set, func))
+            # add an expanded attribute to the function with the information to use in dispatch_with_args
+            # to inject properly only the expanded arguments the function can accept
+            # if .expanded is None => inject all
+            # if .expanded is a list => inject only
+            func.expanded = DjangoDash.get_expanded_arguments(func, inputs, state)
+            return func
+        return wrap_func
+
+    expanded_callback = callback
 
     def clientside_callback(self, clientside_function, output, inputs=None, state=None):
         'Form a callback function by wrapping, in the same way as the underlying Dash application would'
@@ -354,12 +383,20 @@ class PseudoFlask(Flask):
     def register_blueprint(self, *args, **kwargs):
         pass
 
+
+def compare(id_python, id_dash):
+    """Compare an id of a dash component as a python object with an id of a component
+    in dash syntax. It handles both id as str or as dict (pattern-matching)"""
+    if isinstance(id_python, dict):
+        return "{" in id_dash and id_python == json.loads(id_dash)
+    return id_python == id_dash
+
+
 class WrappedDash(Dash):
     'Wrapper around the Plotly Dash application instance'
     # 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,
+                 base_pathname=None, replacements=None, ndid=None, serve_locally=False,
                  **kwargs):
 
         self._uid = ndid
@@ -378,7 +415,6 @@ class WrappedDash(Dash):
         self.scripts.config.serve_locally = serve_locally
 
         self._adjust_id = False
-        self._dash_dispatch = not expanded_callbacks
         if replacements:
             self._replacements = replacements
         else:
@@ -387,10 +423,6 @@ class WrappedDash(Dash):
 
         self._return_embedded = False
 
-    def use_dash_dispatch(self):
-        'Indicate if dispatch is using underlying dash code or the wrapped code'
-        return self._dash_dispatch
-
     def use_dash_layout(self):
         '''
         Indicate if the underlying dash layout can be used.
@@ -413,10 +445,16 @@ class WrappedDash(Dash):
         if initial_arguments:
             if isinstance(initial_arguments, str):
                 initial_arguments = json.loads(initial_arguments)
+        else:
+            initial_arguments = {}
+
+        # Define overrides as self._replacements updated with initial_arguments
+        overrides = dict(self._replacements)
+        overrides.update(initial_arguments)
 
         # 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, initial_arguments)
+        reworked_data = self.walk_tree_and_replace(baseData, overrides)
 
         response_data = json.dumps(reworked_data,
                                    cls=PlotlyJSONEncoder)
@@ -450,10 +488,15 @@ class WrappedDash(Dash):
             replacements = {}
             # look for id entry
             thisID = data.get('id', None)
-            if thisID is not None:
-                replacements = overrides.get(thisID, None) if overrides else None
-                if not replacements:
-                    replacements = self._replacements.get(thisID, {})
+            if isinstance(thisID, dict):
+                # handle case of thisID being a dict (pattern) => linear search in overrides dict
+                for k, v in overrides.items():
+                    if compare(id_python=thisID, id_dash=k):
+                        replacements = v
+                        break
+            elif thisID is not None:
+                # handle standard case of string thisID => key lookup
+                replacements = overrides.get(thisID, {})
             # walk all keys and replace if needed
             for k, v in data.items():
                 r = replacements.get(k, None)
@@ -543,8 +586,6 @@ class WrappedDash(Dash):
 
         if isinstance(output, (list, tuple)):
             fixed_outputs = [self._fix_callback_item(x) for x in output]
-            # Temporary check; can be removed once the library has been extended
-            raise NotImplementedError("django-plotly-dash cannot handle multiple callback outputs at present")
         else:
             fixed_outputs = self._fix_callback_item(output)
 
@@ -605,7 +646,7 @@ class WrappedDash(Dash):
 
         for component_registration in callback_info['inputs']:
             for c in inputs:
-                if c['property'] == component_registration['property'] and c['id'] == component_registration['id']:
+                if c['property'] == component_registration['property'] and compare(id_python=c['id'],id_dash=component_registration['id']):
                     v = c.get('value', None)
                     args.append(v)
                     if da:
@@ -613,7 +654,7 @@ class WrappedDash(Dash):
 
         for component_registration in callback_info['state']:
             for c in states:
-                if c['property'] == component_registration['property'] and c['id'] == component_registration['id']:
+                if c['property'] == component_registration['property'] and compare(id_python=c['id'],id_dash=component_registration['id']):
                     v = c.get('value', None)
                     args.append(v)
                     if da:
@@ -630,7 +671,14 @@ class WrappedDash(Dash):
         if len(args) < len(callback_info['inputs']):
             return 'EDGECASEEXIT'
 
-        res = callback_info['callback'](*args, **argMap)
+        callback = callback_info["callback"]
+        # smart injection of parameters if .expanded is defined
+        if callback.expanded is not None:
+            parameters_to_inject = {*callback.expanded, 'outputs_list'}
+            res = callback(*args, **{k: v for k, v in argMap.items() if k in parameters_to_inject})
+        else:
+            res = callback(*args, **argMap)
+
         if da:
             root_value = json.loads(res).get('response', {})
 
@@ -640,6 +688,9 @@ class WrappedDash(Dash):
                     if da.have_current_state_entry(output_id, output_property):
                         value = root_value.get(output_id,{}).get(output_property, None)
                         da.update_current_state(output_id, output_property, value)
+                else:
+                    # todo: implement saving of state for pattern matching ouputs
+                    raise NotImplementedError("Updating state for dict keys (pattern matching) is not yet implemented")
 
         return res
 
diff --git a/django_plotly_dash/routing.py b/django_plotly_dash/routing.py
index 7015cea..10cd9c6 100644
--- a/django_plotly_dash/routing.py
+++ b/django_plotly_dash/routing.py
@@ -27,6 +27,7 @@ from channels.auth import AuthMiddlewareStack
 from channels.http import AsgiHandler
 
 from django.conf.urls import url
+from django.urls import re_path
 
 from .consumers import MessageConsumer, PokePipeConsumer
 from .util import pipe_ws_endpoint_name, http_endpoint, http_poke_endpoint_enabled
@@ -37,11 +38,11 @@ http_routes = [
     ]
 
 if http_poke_endpoint_enabled():
-    http_routes.append(url(http_endpoint("poke"), PokePipeConsumer))
+    http_routes.append(re_path(http_endpoint("poke"), PokePipeConsumer))
 
-http_routes.append(url("^", AsgiHandler)) # AsgiHandler is 'the normal Django view handlers'
+http_routes.append(re_path("^", AsgiHandler)) # AsgiHandler is 'the normal Django view handlers'
 
 application = ProtocolTypeRouter({
-    'websocket': AuthMiddlewareStack(URLRouter([url(pipe_ws_endpoint_name(), MessageConsumer),])),
+    'websocket': AuthMiddlewareStack(URLRouter([re_path(pipe_ws_endpoint_name(), MessageConsumer),])),
     'http': AuthMiddlewareStack(URLRouter(http_routes)),
     })
diff --git a/django_plotly_dash/views.py b/django_plotly_dash/views.py
index 656fd7c..bcbc2bc 100644
--- a/django_plotly_dash/views.py
+++ b/django_plotly_dash/views.py
@@ -81,27 +81,16 @@ def _update(request, ident, stateless=False, **kwargs):
 
     request_body = json.loads(request.body.decode('utf-8'))
 
-    if app.use_dash_dispatch():
-        # Force call through dash
-        view_func = app.locate_endpoint_function('dash-update-component')
-
-        import flask
-        with app.test_request_context():
-            # Fudge request object
-            # pylint: disable=protected-access
-            flask.request._cached_json = (request_body, flask.request._cached_json[True])
-            resp = view_func()
-    else:
-        # Use direct dispatch with extra arguments in the argMap
-        app_state = request.session.get("django_plotly_dash", dict())
-        arg_map = {'dash_app_id': ident,
-                   'dash_app': dash_app,
-                   'user': request.user,
-                   'request':request,
-                   'session_state': app_state}
-        resp = app.dispatch_with_args(request_body, arg_map)
-        request.session['django_plotly_dash'] = app_state
-        dash_app.handle_current_state()
+    # Use direct dispatch with extra arguments in the argMap
+    app_state = request.session.get("django_plotly_dash", dict())
+    arg_map = {'dash_app_id': ident,
+               'dash_app': dash_app,
+               'user': request.user,
+               'request':request,
+               'session_state': app_state}
+    resp = app.dispatch_with_args(request_body, arg_map)
+    request.session['django_plotly_dash'] = app_state
+    dash_app.handle_current_state()
 
     # Special for ws-driven edge case
     if str(resp) == 'EDGECASEEXIT':
diff --git a/docs/demo_notes.rst b/docs/demo_notes.rst
index dab931e..37238a0 100644
--- a/docs/demo_notes.rst
+++ b/docs/demo_notes.rst
@@ -87,10 +87,10 @@ are used to form the layout::
         ]
     )
 
-Within the :ref:`expanded callback <extended_callbacks>`, the session state is passed as an extra
+Within the :ref:`extended callback <extended_callbacks>`, the session state is passed as an extra
 argument compared to the standard ``Dash`` callback::
 
-    @dis.expanded_callback(
+    @dis.callback(
         dash.dependencies.Output("danger-alert", 'children'),
         [dash.dependencies.Input('update-button', 'n_clicks'),]
         )
diff --git a/docs/extended_callbacks.rst b/docs/extended_callbacks.rst
index 459f892..02ef631 100644
--- a/docs/extended_callbacks.rst
+++ b/docs/extended_callbacks.rst
@@ -5,9 +5,13 @@ Extended callback syntax
 
 The ``DjangoDash`` class allows callbacks to request extra arguments when registered.
 
-To do this, simply replace ``callback`` with ``expanded_callback`` when registering any callback. This will cause **all** of the callbacks
-registered with this application
-to receive extra ``kwargs`` in addition to the callback parameters.
+To do this, simply add to your callback function the extra arguments you would like to receive
+after the usual parameters for your ``Input`` and ``State``.
+This will cause these callbacks registered with this application to receive extra parameters
+in addition to their usual callback parameters.
+
+If you specify a ``kwargs`` in your callback, it will receive all possible extra parameters (see below for a list).
+If you specify explicitly extra parameters from the list below, only these will be passed to your callback.
 
 For example, the ``plotly_apps.py`` example contains this dash application:
 
@@ -28,19 +32,18 @@ For example, the ``plotly_apps.py`` example contains this dash application:
       html.Div(id="output-one")
       ])
 
-  @a2.expanded_callback(
+  @a2.callback(
       dash.dependencies.Output('output-one','children'),
       [dash.dependencies.Input('dropdown-one','value')]
       )
-
   def callback_c(*args,**kwargs):
       da = kwargs['dash_app']
-      return "Args are [%s] and kwargs are %s" %(",".join(args),str(kwargs))
+      return "Args are [%s] and kwargs are %s" %(",".join(args), kwargs)
 
 The additional arguments, which are reported as the ``kwargs`` content in this example, include
 
 :callback_context: The ``Dash`` callback context. See the `documentation <https://dash.plotly.com/advanced-callbacks`_ on the content of
-                   this variable. This variable is provided as an argument to the expanded callback as well as
+                   this variable. This variable is provided as an argument to the callback as well as
                    the ``dash.callback_context`` global variable.
 :dash_app: For stateful applications, the ``DashApp`` model instance
 :dash_app_id: The application identifier. For stateless applications, this is the (slugified) name given to the ``DjangoDash`` constructor.
@@ -50,6 +53,25 @@ The additional arguments, which are reported as the ``kwargs`` content in this e
                 callback are persisted as part of the Django session framework.
 :user: The Django User instance.
 
+Possible alternatives to ``kwargs``
+
+.. code-block:: python
+
+  @a2.callback(
+      dash.dependencies.Output('output-one','children'),
+      [dash.dependencies.Input('dropdown-one','value')]
+      )
+  def callback_c(*args, dash_app):
+      return "Args are [%s] and the extra parameter dash_app is %s" %(",".join(args), dash_app)
+
+  @a2.callback(
+      dash.dependencies.Output('output-one','children'),
+      [dash.dependencies.Input('dropdown-one','value')]
+      )
+  def callback_c(*args, dash_app, **kwargs):
+      return "Args are [%s], the extra parameter dash_app is %s and kwargs are %s" %(",".join(args), dash_app, kwargs)
+
+
 The ``DashApp`` model instance can also be configured to persist itself on any change. This is discussed
 in the :ref:`models_and_state` section.
 


Test patch:
diff --git a/django_plotly_dash/tests.py b/django_plotly_dash/tests.py
index 9a70837..fddd701 100644
--- a/django_plotly_dash/tests.py
+++ b/django_plotly_dash/tests.py
@@ -31,6 +31,9 @@ import pytest
 import json
 
 #pylint: disable=bare-except
+from dash.dependencies import Input
+
+from django_plotly_dash import DjangoDash
 
 
 def test_dash_app():
@@ -201,6 +204,58 @@ def test_injection_updating_multiple_callbacks(client):
         assert resp_detail['output-two']['children'] == "Output 2: 10 purple-ish yellow with a hint of greeny orange []"
 
 
+@pytest.mark.django_db
+def test_flexible_expanded_callbacks(client):
+    'Check updating of an app using demo test data for flexible expanded callbacks'
+
+    from django.urls import reverse
+
+    route_name = 'update-component'
+
+    for prefix, arg_map in [('app-', {'ident':'flexible_expanded_callbacks'}),]:
+        url = reverse('the_django_plotly_dash:%s%s' % (prefix, route_name), kwargs=arg_map)
+
+        # output contains all arguments of the expanded_callback
+        response = client.post(url, json.dumps({'output':'output-one.children',
+                                                'inputs':[
+            {'id':'button',
+             'property':'n_clicks',
+             'value':'10'},
+                                                         ]}), content_type="application/json")
+
+        assert response.status_code == 200
+
+        resp = json.loads(response.content.decode('utf-8'))
+        for key in ["dash_app_id", "dash_app", "callback_context"]:
+            assert key in resp["response"]['output-one']['children']
+
+        # output contains all arguments of the expanded_callback
+        response = client.post(url, json.dumps({'output':'output-two.children',
+                                                'inputs':[
+            {'id':'button',
+             'property':'n_clicks',
+             'value':'10'},
+                                                         ]}), content_type="application/json")
+
+        assert response.status_code == 200
+
+        resp = json.loads(response.content.decode('utf-8'))
+        assert resp["response"]=={'output-two': {'children': 'ok'}}
+
+
+        # output contains all arguments of the expanded_callback
+        response = client.post(url, json.dumps({'output':'output-three.children',
+                                                'inputs':[
+            {'id':'button',
+             'property':'n_clicks',
+             'value':'10'},
+                                                         ]}), content_type="application/json")
+
+        assert response.status_code == 200
+
+        resp = json.loads(response.content.decode('utf-8'))
+        assert resp["response"]=={"output-three": {"children": "flexible_expanded_callbacks"}}
+
 @pytest.mark.django_db
 def test_injection_updating(client):
     'Check updating of an app using demo test data'
@@ -253,7 +308,6 @@ def test_injection_updating(client):
                                                            'value':'TestIt'},
                                                          ]}), content_type="application/json")
 
-
         # Multiple output callback, output=="..component_id.component_prop.."
         response = client.post(url, json.dumps({'output':'..test-output-div3.children..',
                                                 'inputs':[{'id':'my-dropdown1',
@@ -392,3 +446,46 @@ def test_app_loading(client):
     assert response.status_code == 302
 
 
+def test_callback_decorator():
+    inputs = [Input("one", "value"),
+              Input("two", "value"),
+              ]
+    states = [Input("three", "value"),
+              Input("four", "value"),
+              ]
+
+    def callback_standard(one, two, three, four):
+        return
+
+    assert DjangoDash.get_expanded_arguments(callback_standard, inputs, states) == []
+
+    def callback_standard(one, two, three, four, extra_1):
+        return
+
+    assert DjangoDash.get_expanded_arguments(callback_standard, inputs, states) == ['extra_1']
+
+    def callback_args(one, *args):
+        return
+
+    assert DjangoDash.get_expanded_arguments(callback_args, inputs, states) == []
+
+    def callback_args_extra(one, *args, extra_1):
+        return
+
+    assert DjangoDash.get_expanded_arguments(callback_args_extra, inputs, states) == ['extra_1' ]
+
+    def callback_args_extra_star(one, *, extra_1):
+        return
+
+    assert DjangoDash.get_expanded_arguments(callback_args_extra_star, inputs, states) == ['extra_1' ]
+
+
+    def callback_kwargs(one, two, three, four, extra_1, **kwargs):
+        return
+
+    assert DjangoDash.get_expanded_arguments(callback_kwargs, inputs, states) == None
+
+    def callback_kwargs(one, two, three, four, *, extra_1, **kwargs, ):
+        return
+
+    assert DjangoDash.get_expanded_arguments(callback_kwargs, inputs, states) == None