Repository: django-oscar/django-oscar

Issue:
Class loader throws an error after forking dashboard
## Issue Summary

Using a forked dashboard application results in an error.

## Steps to Reproduce

1. Set up an Oscar project named `foo` as described in the section titled "Building your own shop"
2. Run `./manage.py oscar_fork_app dashboard foo`
3. Substitute `foo.dashboard` for `oscar.apps.dashboard` in `foo/settings.py`
4. Run `./manage.py`

The following exception is thrown:

<pre>Traceback (most recent call last):
  File "./manage.py", line 22, in <module>
    execute_from_command_line(sys.argv)
  File "/usr/lib/python3.8/site-packages/django/core/management/__init__.py", line 364, in execute_from_command_line
    utility.execute()
  File "/usr/lib/python3.8/site-packages/django/core/management/__init__.py", line 338, in execute
    django.setup()
  File "/usr/lib/python3.8/site-packages/django/__init__.py", line 27, in setup
    apps.populate(settings.INSTALLED_APPS)
  File "/usr/lib/python3.8/site-packages/django/apps/registry.py", line 116, in populate
    app_config.ready()
  File "/usr/lib/python3.8/site-packages/oscar/apps/dashboard/reports/apps.py", line 16, in ready
    self.index_view = get_class('dashboard.reports.views', 'IndexView')
  File "/usr/lib/python3.8/site-packages/oscar/core/loading.py", line 31, in get_class
    return get_classes(module_label, [classname], module_prefix)[0]
  File "/usr/lib/python3.8/site-packages/oscar/core/loading.py", line 41, in get_classes
    return class_loader(module_label, classnames, module_prefix)
  File "/usr/lib/python3.8/site-packages/oscar/core/loading.py", line 99, in default_class_loader
    oscar_module = _import_module(oscar_module_label, classnames)
  File "/usr/lib/python3.8/site-packages/oscar/core/loading.py", line 133, in _import_module
    return __import__(module_label, fromlist=classnames)
  File "/usr/lib/python3.8/site-packages/oscar/apps/dashboard/reports/views.py", line 9, in <module>
    ReportForm = get_class('dashboard.reports.forms', 'ReportForm')
  File "/usr/lib/python3.8/site-packages/oscar/core/loading.py", line 31, in get_class
    return get_classes(module_label, [classname], module_prefix)[0]
  File "/usr/lib/python3.8/site-packages/oscar/core/loading.py", line 41, in get_classes
    return class_loader(module_label, classnames, module_prefix)
  File "/usr/lib/python3.8/site-packages/oscar/core/loading.py", line 99, in default_class_loader
    oscar_module = _import_module(oscar_module_label, classnames)
  File "/usr/lib/python3.8/site-packages/oscar/core/loading.py", line 133, in _import_module
    return __import__(module_label, fromlist=classnames)
  File "/usr/lib/python3.8/site-packages/oscar/apps/dashboard/reports/forms.py", line 7, in <module>
    GeneratorRepository = get_class('dashboard.reports.utils',
  File "/usr/lib/python3.8/site-packages/oscar/core/loading.py", line 31, in get_class
    return get_classes(module_label, [classname], module_prefix)[0]
  File "/usr/lib/python3.8/site-packages/oscar/core/loading.py", line 41, in get_classes
    return class_loader(module_label, classnames, module_prefix)
  File "/usr/lib/python3.8/site-packages/oscar/core/loading.py", line 99, in default_class_loader
    oscar_module = _import_module(oscar_module_label, classnames)
  File "/usr/lib/python3.8/site-packages/oscar/core/loading.py", line 133, in _import_module
    return __import__(module_label, fromlist=classnames)
  File "/usr/lib/python3.8/site-packages/oscar/apps/dashboard/reports/utils.py", line 3, in <module>
    OrderReportGenerator = get_class('order.reports', 'OrderReportGenerator')
  File "/usr/lib/python3.8/site-packages/oscar/core/loading.py", line 31, in get_class
    return get_classes(module_label, [classname], module_prefix)[0]
  File "/usr/lib/python3.8/site-packages/oscar/core/loading.py", line 41, in get_classes
    return class_loader(module_label, classnames, module_prefix)
  File "/usr/lib/python3.8/site-packages/oscar/core/loading.py", line 99, in default_class_loader
    oscar_module = _import_module(oscar_module_label, classnames)
  File "/usr/lib/python3.8/site-packages/oscar/core/loading.py", line 133, in _import_module
    return __import__(module_label, fromlist=classnames)
  File "/usr/lib/python3.8/site-packages/oscar/apps/order/reports.py", line 7, in <module>
    ReportGenerator = get_class('dashboard.reports.reports', 'ReportGenerator')
  File "/usr/lib/python3.8/site-packages/oscar/core/loading.py", line 31, in get_class
    return get_classes(module_label, [classname], module_prefix)[0]
  File "/usr/lib/python3.8/site-packages/oscar/core/loading.py", line 41, in get_classes
    return class_loader(module_label, classnames, module_prefix)
  File "/usr/lib/python3.8/site-packages/oscar/core/loading.py", line 104, in default_class_loader
    app_name = _find_registered_app_name(module_label)
  File "/usr/lib/python3.8/site-packages/oscar/core/loading.py", line 188, in _find_registered_app_name
    raise AppNotFoundError(
oscar.core.exceptions.AppNotFoundError: Couldn't find an Oscar app to import dashboard.reports.reports from
</pre>

## Technical Details

* Python version: 3.8.1
* Django version: 1.11.28
* Oscar version: 2.0.4



Discussion:
I suspect this is because you haven't forked any of the dashboard's sub-apps? You need to fork at least one of those as well - e.g., you need `foo.dashboard.catalogue` to exist in addition to `foo.dashboard`.
I did some further investigation on this. Forking additional dashboard applications did not solve the problem, but using`foo.dashbord.apps.DashboardConfig` in `INSTALLED_APPS` or adding
`default_app_config = 'foo.dashbord.apps.DashboardConfig'` to `foo/dashboard/__init__.py` did the trick.

According to the section titled "Customising Oscar" in the documentation, using the module path should suffice. However, the current implementation of `oscar_fork_app` no longer sets `default_app_config`. Is there a good reason for not setting it?

I think this was probably just an oversight. The documentation has been updated on master to [use the explicit app config](https://github.com/django-oscar/django-oscar/blob/master/docs/source/topics/customisation.rst), will try and get that onto readthedocs soon.

Leaving this open as a reminder to look into the `default_app_config`.

Fix patch:
diff --git a/src/oscar/core/customisation.py b/src/oscar/core/customisation.py
--- a/src/oscar/core/customisation.py
+++ b/src/oscar/core/customisation.py
@@ -36,6 +36,11 @@ def subfolders(path):
 
 
 def inherit_app_config(local_app_folder_path, local_app_name, app_config):
+    create_file(
+        join(local_app_folder_path, '__init__.py'),
+        "default_app_config = '{app_name}.apps.{app_config_class_name}'\n".format(
+            app_name=local_app_name,
+            app_config_class_name=app_config.__class__.__name__))
     create_file(
         join(local_app_folder_path, 'apps.py'),
         "import {app_config_class_module} as apps\n\n\n"


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -15,3 +15,16 @@ def __enter__(self):
 
     def __exit__(self, exc_type, exc_value, traceback):
         sys.path = self.original_paths
+
+
+def delete_from_import_cache(module_name):
+    """
+    Deletes imported modules from the cache, so that they do not interfere with
+    subsequent imports of different modules of the same names.
+
+    Useful in situations where dynamically-created files are imported.
+    """
+    parts = module_name.split('.')
+    for i, _ in enumerate(parts, 1):
+        submodule_name = '.'.join(parts[:i])
+        del sys.modules[submodule_name]
diff --git a/tests/integration/core/test_customisation.py b/tests/integration/core/test_customisation.py
--- a/tests/integration/core/test_customisation.py
+++ b/tests/integration/core/test_customisation.py
@@ -8,6 +8,7 @@
 from django.test import TestCase, override_settings
 
 from oscar.core import customisation
+from tests import delete_from_import_cache
 
 VALID_FOLDER_PATH = 'tests/_site/apps'
 
@@ -39,12 +40,22 @@ def test_creates_new_folder(tmpdir):
     path.join('order').ensure_dir()
 
 
-def test_creates_init_file(tmpdir):
+def test_creates_init_file(tmpdir, monkeypatch):
     path = tmpdir.mkdir('fork')
     customisation.fork_app('order', str(path), 'order')
 
     path.join('order').join('__init__.py').ensure()
 
+    monkeypatch.syspath_prepend(str(tmpdir))
+
+    config_module = __import__('fork.order.apps', fromlist=['OrderConfig'])
+    delete_from_import_cache('fork.order.apps')
+
+    expected_string = "default_app_config = '{}.apps.OrderConfig".format(
+        config_module.OrderConfig.name)
+    contents = path.join('order').join('__init__.py').read()
+    assert expected_string in contents
+
 
 def test_handles_dashboard_app(tmpdir):
     # Dashboard apps are fiddly as they aren't identified by a single app