Преглед на файлове

Fix dashboard permissions for forked apps

When looking up a URL for a forked app, the previous mechanism fetched the top level AppConfig which allows every user.
The new mechanism creates a dict of dashboard URLs/configs and does a lookup to find a DashboardConfig.

This will also raise NoReverseMatch if the URL does not exist or is not a dashboard URL.

fix https://github.com/django-oscar/django-oscar/issues/3080
master
Anthony Ricaud преди 6 години
родител
ревизия
41c3443a2c

+ 41
- 69
src/oscar/apps/dashboard/nav.py Целия файл

@@ -1,14 +1,11 @@
1
-import inspect
2 1
 import logging
3
-import sys
4
-from collections import OrderedDict
2
+from functools import lru_cache
5 3
 
6 4
 from django.apps import apps
7 5
 from django.core.exceptions import ImproperlyConfigured
8 6
 from django.urls import NoReverseMatch, resolve, reverse
9 7
 
10 8
 from oscar.core.application import OscarDashboardConfig
11
-from oscar.core.exceptions import AppNotFoundError
12 9
 from oscar.views.decorators import check_permissions
13 10
 
14 11
 logger = logging.getLogger('oscar.dashboard')
@@ -62,78 +59,53 @@ class Node(object):
62 59
         return len(self.children) > 0
63 60
 
64 61
 
65
-def default_access_fn(user, url_name, url_args=None, url_kwargs=None):  # noqa C901 too complex
62
+@lru_cache(maxsize=1)
63
+def _dashboard_url_names_to_config():
64
+    dashboard_configs = (
65
+        config
66
+        for config in apps.get_app_configs()
67
+        if isinstance(config, OscarDashboardConfig)
68
+    )
69
+    urls_to_config = {}
70
+    for config in dashboard_configs:
71
+        for url in config.urls[0]:
72
+            # includes() don't have a name attribute
73
+            # We skipped them because they come from other AppConfigs
74
+            name = getattr(url, 'name', None)
75
+            if not name:
76
+                continue
77
+
78
+            if name in urls_to_config:
79
+                if urls_to_config[name] != config:
80
+                    raise ImproperlyConfigured(
81
+                        "'{}' exists in both {} and {}!".format(
82
+                            name, config, urls_to_config[name]
83
+                        )
84
+                    )
85
+
86
+            urls_to_config[name] = config
87
+    return urls_to_config
88
+
89
+
90
+def default_access_fn(user, url_name, url_args=None, url_kwargs=None):
66 91
     """
67
-    Given a url_name and a user, this function tries to assess whether the
92
+    Given a user and a url_name, this function assesses whether the
68 93
     user has the right to access the URL.
69
-    The application instance of the view is fetched via the Django app
70
-    registry.
71 94
     Once the permissions for the view are known, the access logic used
72 95
     by the dashboard decorator is evaluated
73
-
74
-    This function might seem costly, but a simple comparison with DTT
75
-    did not show any change in response time
76 96
     """
77 97
     if url_name is None:  # it's a heading
78 98
         return True
79 99
 
80
-    # get view module string.
100
+    url = reverse(url_name, args=url_args, kwargs=url_kwargs)
101
+    url_match = resolve(url)
102
+    url_name = url_match.url_name
81 103
     try:
82
-        url = reverse(url_name, args=url_args, kwargs=url_kwargs)
83
-    except NoReverseMatch:
84
-        # In Oscar 1.5 this exception was silently ignored which made debugging
85
-        # very difficult. Now it is being logged and in future the exception will
86
-        # be propagated.
87
-        logger.exception('Invalid URL name {}'.format(url_name))
88
-        return False
89
-
90
-    view_module = resolve(url).func.__module__
91
-
92
-    # We can't assume that the view has the same parent module as the app
93
-    # config, as either the app config or view can be customised. So we first
94
-    # look it up in the app registry using "get_containing_app_config", and if
95
-    # it isn't found, then we walk up the package tree, looking for an
96
-    # OscarDashboardConfig class, from which we get an app label, and use that
97
-    # to look it up again in the app registry using "get_app_config".
98
-    app_config_instance = apps.get_containing_app_config(view_module)
99
-    if app_config_instance is None:
100
-        try:
101
-            app_config_class = get_app_config_class(view_module)
102
-        except AppNotFoundError:
103
-            raise ImproperlyConfigured(
104
-                "Please provide an OscarDashboardConfig subclass in the apps "
105
-                "module or set a custom access_fn")
106
-        if hasattr(app_config_class, 'label'):
107
-            app_label = app_config_class.label
108
-        else:
109
-            app_label = app_config_class.name.rpartition('.')[2]
110
-        try:
111
-            app_config_instance = apps.get_app_config(app_label)
112
-        except LookupError:
113
-            raise AppNotFoundError(
114
-                "Couldn't find an app with the label %s" % app_label)
115
-        if not isinstance(app_config_instance, OscarDashboardConfig):
116
-            raise AppNotFoundError(
117
-                "Couldn't find an Oscar Dashboard app with the label %s" % app_label)
118
-
119
-    # handle name-spaced view names
120
-    if ':' in url_name:
121
-        view_name = url_name.split(':')[1]
122
-    else:
123
-        view_name = url_name
124
-    permissions = app_config_instance.get_permissions(view_name)
125
-    return check_permissions(user, permissions)
126
-
104
+        app_config_instance = _dashboard_url_names_to_config()[url_name]
105
+    except KeyError:
106
+        raise NoReverseMatch(
107
+            "{} is not a valid dashboard URL".format(url_match.view_name)
108
+        )
109
+    permissions = app_config_instance.get_permissions(url_name)
127 110
 
128
-def get_app_config_class(module_name):
129
-    apps_module_name = module_name.rpartition('.')[0] + '.apps'
130
-    if apps_module_name in sys.modules:
131
-        oscar_dashboard_config_classes = []
132
-        apps_module_classes = inspect.getmembers(sys.modules[apps_module_name], inspect.isclass)
133
-        for klass in OrderedDict(apps_module_classes).values():
134
-            if issubclass(klass, OscarDashboardConfig):
135
-                oscar_dashboard_config_classes.append(klass)
136
-        if oscar_dashboard_config_classes:
137
-            return oscar_dashboard_config_classes[-1]
138
-    raise AppNotFoundError(
139
-        "Couldn't find an app to import %s from" % module_name)
111
+    return check_permissions(user, permissions)

+ 1
- 0
tests/_site/apps/dashboard/__init__.py Целия файл

@@ -0,0 +1 @@
1
+default_app_config = 'tests._site.apps.dashboard.apps.DashboardConfig'

+ 5
- 0
tests/_site/apps/dashboard/apps.py Целия файл

@@ -0,0 +1,5 @@
1
+from oscar.apps.dashboard import apps
2
+
3
+
4
+class DashboardConfig(apps.DashboardConfig):
5
+    name = 'tests._site.apps.dashboard'

+ 1
- 0
tests/_site/apps/dashboard/models.py Целия файл

@@ -0,0 +1 @@
1
+from oscar.apps.dashboard.models import *  # noqa

+ 9
- 1
tests/integration/dashboard/test_nav.py Целия файл

@@ -1,4 +1,6 @@
1
+import pytest
1 2
 from django.test import TestCase
3
+from django.urls import NoReverseMatch, reverse
2 4
 
3 5
 from oscar.apps.dashboard.menu import get_nodes
4 6
 from oscar.apps.dashboard.nav import default_access_fn
@@ -21,7 +23,13 @@ class DashboardAccessFunctionTestCase(TestCase):
21 23
         self.assertFalse(default_access_fn(self.non_staff_user, 'dashboard:index'))
22 24
 
23 25
     def test_default_access_fn_invalid_url_name(self):
24
-        self.assertFalse(default_access_fn(self.staff_user, 'invalid_module:index'))
26
+        with pytest.raises(NoReverseMatch):
27
+            default_access_fn(self.staff_user, 'invalid_module:index')
28
+
29
+    def test_default_access_non_dashboard_url_name(self):
30
+        assert reverse('search:search')
31
+        with pytest.raises(NoReverseMatch):
32
+            default_access_fn(self.staff_user, 'search:search')
25 33
 
26 34
 
27 35
 class DashboardNavTestCase(TestCase):

+ 1
- 0
tests/settings.py Целия файл

@@ -75,6 +75,7 @@ INSTALLED_APPS = [
75 75
 INSTALLED_APPS[INSTALLED_APPS.index('oscar.apps.partner')] = 'tests._site.apps.partner'
76 76
 INSTALLED_APPS[INSTALLED_APPS.index('oscar.apps.customer')] = 'tests._site.apps.customer'
77 77
 INSTALLED_APPS[INSTALLED_APPS.index('oscar.apps.catalogue')] = 'tests._site.apps.catalogue'
78
+INSTALLED_APPS[INSTALLED_APPS.index('oscar.apps.dashboard')] = 'tests._site.apps.dashboard'
78 79
 
79 80
 AUTH_USER_MODEL = 'myauth.User'
80 81
 

+ 52
- 0
tests/unit/dashboard/test_nav.py Целия файл

@@ -0,0 +1,52 @@
1
+from unittest import mock
2
+
3
+import pytest
4
+from django.apps import AppConfig
5
+from django.conf.urls import include, url
6
+from django.core.exceptions import ImproperlyConfigured
7
+
8
+from oscar.apps.dashboard.nav import _dashboard_url_names_to_config
9
+from oscar.core.application import OscarDashboardConfig
10
+
11
+
12
+class PathAppConfig(AppConfig):
13
+    path = 'fake'
14
+
15
+
16
+class DashConfig(OscarDashboardConfig):
17
+    path = 'fake'
18
+
19
+    def get_urls(self):
20
+        return [
21
+            url('a', lambda x:x, name='lol'),
22
+            url('b', lambda x:x),
23
+            url('c', include([
24
+                url('d', lambda x:x, name='foo'),
25
+            ]))
26
+        ]
27
+
28
+
29
+def test_only_returns_dashboard_urls():
30
+    with mock.patch('oscar.apps.dashboard.nav.apps.get_app_configs') as mock_configs:
31
+        mock_configs.return_value = [PathAppConfig('name', 'module')]
32
+        output = _dashboard_url_names_to_config.__wrapped__()
33
+
34
+    assert not output
35
+
36
+
37
+def test_only_returns_named_urls_and_skips_includes():
38
+    config = DashConfig('name', 'module')
39
+    with mock.patch('oscar.apps.dashboard.nav.apps.get_app_configs') as mock_configs:
40
+        mock_configs.return_value = [config]
41
+        output = _dashboard_url_names_to_config.__wrapped__()
42
+
43
+    assert output == {"lol": config}
44
+
45
+
46
+def test_raises_if_same_name_in_different_configs():
47
+    config_a = DashConfig('a_name', 'a_module')
48
+    config_b = DashConfig('b_name', 'b_module')
49
+    with mock.patch('oscar.apps.dashboard.nav.apps.get_app_configs') as mock_configs:
50
+        mock_configs.return_value = [config_a, config_b]
51
+        with pytest.raises(ImproperlyConfigured):
52
+            _dashboard_url_names_to_config.__wrapped__()

Loading…
Отказ
Запис