8000 Introduced app registration system by monikasulik · Pull Request #6421 · django-cms/django-cms · GitHub
[go: up one dir, main page]

Skip to content

Introduced app registration system #6421

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 28 commits into from
Jun 26, 2018
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4f4139e
App registry: autodiscover
monikasulik Jun 14, 2018
7db2fc9
App registration: Add cms_app attr to django app config
monikasulik Jun 14, 2018
0394871
App registration: run extension methods
monikasulik Jun 15, 2018
0ee728e
App registration: Some clean up
monikasulik Jun 15, 2018
f79f575
App registration: Edge cases
monikasulik Jun 15, 2018
f329f22
App registration: Configure CMS apps
monikasulik Jun 18, 2018
b5f1825
App registration: More clean up
monikasulik Jun 18, 2018
5b88aa1
Merge pull request #3 from monikasulik/app_registration_config
monikasulik Jun 19, 2018
c9f064d
App registration: Test comments
monikasulik Jun 19, 2018
e53eac3
App registration: Integrate into start up (WIP)
monikasulik Jun 21, 2018
f2299db
App registration: Clean up
monikasulik Jun 21, 2018
2416687
Merge pull request #4 from monikasulik/app_registration_start_up
monikasulik Jun 21, 2018
ec35c49
App registration: Remove import test (inconsistent across envs) and u…
monikasulik Jun 21, 2018
5968fc9
App registration: Clean up CMSAppConfig class
monikasulik Jun 21, 2018
c1b606e
Move app registration test apps to test_utils
monikasulik Jun 22, 2018
367e49f
App registration: CHANGELOG and AUTHORS changes
monikasulik Jun 22, 2018
31d767d
Merge pull request #5 from monikasulik/app_registration_improvements
monikasulik Jun 22, 2018
d908a66
App registration: Various minor changes after code review
monikasulik Jun 25, 2018
093408c
App registration: Split app config into 2 classes
monikasulik Jun 25, 2018
263c1c5
App registration: Polish up after separating config base classes
monikasulik Jun 25, 2018
741a803
Merge pull request #6 from monikasulik/app_registration_improvements
monikasulik Jun 25, 2018
bcc02f4
Fix python 2.7 compatibility issue
monikasulik Jun 26, 2018
e64a652
App registration: Improvements after code review
monikasulik Jun 26, 2018
6a55666
Clean up
monikasulik Jun 26, 2018
9f3f6f2
Clean up
monikasulik Jun 26, 2018
6e683d9
Fix abc import for python 2.7
monikasulik Jun 26, 2018
0850fd1
App registration: Clean up
monikasulik Jun 26, 2018
2055642
Minor cleanup
czpython Jun 26, 2018
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 78 additions & 9 deletions cms/app_registration.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,96 @@
import inspect
from importlib import import_module

from django.apps import apps
from django.utils.module_loading import module_has_submodule
from django.core.exceptions import ImproperlyConfigured


CMS_CONFIG_NAME = 'cms_apps'


def autodiscover_cms_files():
"""Find and import all cms_apps.py modules"""
def autodiscover_cms_configs():
"""
Find and import all cms_apps.py files. Add a cms_app attribute
to django's app config with an instance of the cms config.
"""
for app_config in apps.get_app_configs():
try:
import_module('%s.%s' % (app_config.name, CMS_CONFIG_NAME))
except Exception:
cms_module = import_module(
'%s.%s' % (app_config.name, CMS_CONFIG_NAME))
except:
# If something in cms_apps.py raises an exception let that
# exception bubble up. Only catch the exception if
# cms_apps.py doesn't exist
if module_has_submodule(app_config.module, CMS_CONFIG_NAME):
raise
else:
cms_app_classes = []
# Find all classes that inherit from CMSAppConfig
for name, obj in inspect.getmembers(cms_module):
is_cms_app_config = (
inspect.isclass(obj) and
issubclass(obj, CMSAppConfig) and
# Ignore the import of CMSAppConfig itself
obj != CMSAppConfig
)
if is_cms_app_config:
cms_app_classes.append(obj)

if len(cms_app_classes) == 1:
# We are adding this attribute here rather than in
# django's app config definition because there are
# all kinds of limitations as to what can be imported
# in django's apps.py and this could cause issues
app_config.cms_app = cms_app_classes[0]()
else:
raise ImproperlyConfigured(
"cms_apps.py files must define exactly one "
"class which inherits from CMSAppConfig")

class CMSAppConfig():
# Temporary stub
pass

def get_cms_apps_with_features():
"""
Returns app configs of apps with cms features
"""
apps_with_features = []

for app_config in apps.get_app_configs():
# The cms_app attr is added by the autodiscover_cms_configs
# function if a cms_apps.py file with a suitable class is found.
is_cms_app = hasattr(app_config, 'cms_app')
if is_cms_app:
# The configure_app method is only present on the cms
# app class if the app provides a cms feature. For classes
# that only have config this method will not be present.
has_cms_extension = hasattr(
app_config.cms_app, 'configure_app')
else:
has_cms_extension = False
if is_cms_app and has_cms_extension:
apps_with_features.append(app_config)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove newline

return apps_with_features

class CMSAppExtension():
# Temporary stub

def configure_cms_apps(apps_with_features):
"""
Check installed apps for apps that are configured to use cms addons
and run code to register them with their config
"""
for app_with_feature in apps_with_features:
enabled_property = "{app_name}_enabled".format(
app_name=app_with_feature.name)
configure_app = app_with_feature.cms_app.configure_app

for app_config in apps.get_app_configs():
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there should be a function that returns all apps with cms configs.
this can be wrapped with lru_cache for performance

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the spirit of YAGNI, wouldn't it be better to add that when we actually need it rather than before? It's a simple thing to add later if it is needed.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We would use it here, and will be good because then we can compute it once at runtime

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You want one with all cms apps (both extensions and configs) or one with just the cms apps that define a config? Cause actually I think in this case it makes more sense if it's just the apps that have a cms_config attribute.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, just the ones with cms config

if not hasattr(app_config, 'cms_app'):
# Not a cms app, so ignore
continue
if getattr(app_config.cms_app, enabled_property, False):
# Feature enabled for this app so configure
configure_app(app_config)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should pass the cms_config directly here.
It will cover most cases, otherwise everyone would have to do app.cms_config all the time
in their configure_app method and I consider cms_config an internal that we shouldn't expose.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, that will mean that I'll also need to override the __init__ to take the django app config as in your original design. People might need things like app label, verbose_name etc. for things.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that I've just done this and looked at how the code is now, it has occurred to me that it would probably be neater to do def configure_app(cms_config, app_config) rather than override init and do cms_config.app_config 😉 Thoughts?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't like it because it implies that they're different while they're not, most cases will not need to use app_config.



class CMSAppConfig():
pass
220 changes: 201 additions & 19 deletions cms/tests/test_app_registration.py
Original file line number Diff line number Diff line change
@@ -1,50 +1,50 @@
import sys
from mock import patch, Mock

from django.test import override_settings
from django.apps import apps, AppConfig
from django.core.exceptions import ImproperlyConfigured

from cms import app_registration
from cms.test_utils.testcases import CMSTestCase
from cms.app_registration import autodiscover_cms_files


class AutodiscoverTestCase(CMSTestCase):

def setUp(self):
def _clear_autodiscover_imports(self):
"""Helper method to clear imports"""
sys.path_importer_cache.clear()

sys.modules.pop('cms.tests.test_app_registry.app_without_cms_file', None)
sys.modules.pop('cms.tests.test_app_registry', None)
sys.modules.pop('cms.tests.test_app_registry.app_with_cms_file.models', None)
sys.modules.pop('cms.tests.test_app_registry.app_with_cms_extension.models', None)
sys.modules.pop('cms.tests.test_app_registry.app_without_cms_file.models', None)
sys.modules.pop('cms.tests.test_app_registry.app_with_cms_file', None)
sys.modules.pop('cms.tests.test_app_registry.app_with_cms_file.cms_apps', None)
sys.modules.pop('cms.tests.test_app_registry.app_with_cms_extension', None)
sys.modules.pop('cms.tests.test_app_registry.app_with_cms_extension.cms_apps', None)

def tearDown(self):
sys.path_importer_cache.clear()
def setUp(self):
self._clear_autodiscover_imports()

sys.modules.pop('cms.tests.test_app_registry.app_without_cms_file', None)
sys.modules.pop('cms.tests.test_app_registry', None)
sys.modules.pop('cms.tests.test_app_registry.app_with_cms_file.models', None)
sys.modules.pop('cms.tests.test_app_registry.app_without_cms_file.models', None)
sys.modules.pop('cms.tests.test_app_registry.app_with_cms_file', None)
sys.modules.pop('cms.tests.test_app_registry.app_with_cms_file.cms_apps', None)
def tearDown(self):
self._clear_autodiscover_imports()

@override_settings(INSTALLED_APPS=[
'cms.tests.test_app_registry.app_with_cms_file',
'cms.tests.test_app_registry.app_with_cms_extension',
'cms.tests.test_app_registry.app_without_cms_file'
])
def test_imports_cms_apps_files(self):
autodiscover_cms_files()
app_registration.autodiscover_cms_configs()

loaded = set([
str(module) for module in sys.modules
if 'cms.tests.test_app_registry' in module])
expected = set([
'cms.tests.test_app_registry.app_without_cms_file',
'cms.tests.test_app_registry',
'cms.tests.test_app_registry.app_with_cms_file.models',
'cms.tests.test_app_registry.app_with_cms_extension.models',
'cms.tests.test_app_registry.app_without_cms_file.models',
'cms.tests.test_app_registry.app_with_cms_file',
'cms.tests.test_app_registry.app_with_cms_file.cms_apps'
'cms.tests.test_app_registry.app_with_cms_extension',
'cms.tests.test_app_registry.app_with_cms_extension.cms_apps'
])
self.assertSetEqual(loaded, expected)

Expand All @@ -53,4 +53,186 @@ def test_imports_cms_apps_files(self):
])
def test_raises_exception_raised_in_cms_file(self):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rename to test_exception_propagates_from_cms_file

with self.assertRaises(KeyError):
autodiscover_cms_files()
app_registration.autodiscover_cms_configs()

@override_settings(INSTALLED_APPS=[
'cms.tests.test_app_registry.app_with_cms_extension',
'cms.tests.test_app_registry.app_without_cms_file'
])
def test_adds_cms_app_attribute_to_django_app_config(self):
app_registration.autodiscover_cms_configs()

app_list = [app for app in apps.get_app_configs()]
self.assertTrue(hasattr(app_list[0], 'cms_app'))
self.assertEqual(
app_list[0].cms_app.__class__.__name__, 'CMSSomeFeatureConfig')
self.assertFalse(hasattr(app_list[1], 'cms_app'))

@override_settings(INSTALLED_APPS=[
'cms.tests.test_app_registry.app_without_cms_app_class',
])
def test_raises_exception_when_no_cms_app_class_found_in_cms_file(self):
with self.assertRaises(ImproperlyConfigured):
app_registration.autodiscover_cms_configs()

@override_settings(INSTALLED_APPS=[
'cms.tests.test_app_registry.app_with_two_cms_app_classes',
])
def test_raises_exception_when_more_than_one_cms_app_class_found_in_cms_file(self):
with self.assertRaises(ImproperlyConfigured):
app_registration.autodiscover_cms_configs()


class GetCmsAppsWithFeaturesTestCase(CMSTestCase):

@patch.object(apps, 'get_app_configs')
def test_returns_only_apps_with_features(self, mocked_apps):
# apps with cms_app attr and a configure method
app_with_cms_ext1 = Mock(cms_app=Mock(spec=['configure_app']))
app_with_cms_ext2 = Mock(cms_app=Mock(spec=['configure_app']))
# app with cms_app attr without a configure method
# this throws an AttributeError if configure_app is accessed
app_with_cms_config = Mock(cms_app=Mock(spec=[]))
# app without cms_app attr
# this throws an AttributeError if cms_app is accessed
non_cms_app = Mock(spec=[])
# mock what apps have been installed
mocked_apps.return_value = [
app_with_cms_ext1,
app_with_cms_config,
app_with_cms_ext2,
non_cms_app,
]

apps_with_features = app_registration.get_cms_apps_with_features()

self.assertListEqual(
apps_with_features,
[app_with_cms_ext1, app_with_cms_ext2])


class ConfigureCmsAppsTestCase(CMSTestCase):

@patch.object(apps, 'get_app_configs')
def test_runs_configure_app_method_for_app_with_enabled_config(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one line is fine here

self, mocked_apps
):
# Set up app with name djangocms_feature_x that has a cms feature
feature_app = Mock(spec=AppConfig)
feature_app.name = 'djangocms_feature_x'
feature_app.cms_app = Mock(spec=['configure_app'])
# Set up app that makes use of djangocms_feature_x
config_app_enabled = Mock(spec=AppConfig)
config_app_enabled.cms_app = Mock(
spec=['djangocms_feature_x_enabled'])
config_app_enabled.cms_app.djangocms_feature_x_enabled = True
# Pretend these mocked apps are in INSTALLED_APPS
mocked_apps.return_value = [
feature_app, config_app_enabled]

app_registration.configure_cms_apps([feature_app])

feature_app.cms_app.configure_app.assert_called_once_with(
config_app_enabled)

@patch.object(apps, 'get_app_configs')
def test_doesnt_run_configure_app_method_for_disabled_app(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one line

self, mocked_apps
):
# Set up app with name djangocms_feature_x that has a cms feature
feature_app = Mock(spec=AppConfig)
feature_app.name = 'djangocms_feature_x'
feature_app.cms_app = Mock(spec=['configure_app'])
# Set up two apps that do not make use of djangocms_feature_x.
# One does not define the enabled attr at all (most common
# use case) and one defines it as False
config_app_disabled1 = Mock(spec=AppConfig)
config_app_disabled1.cms_app = Mock(spec=[])
config_app_disabled2 = Mock(spec=AppConfig)
config_app_disabled2.cms_app = Mock(
spec=['djangocms_feature_x_enabled'])
config_app_disabled2.cms_app.djangocms_feature_x_enabled = False
# Pretend all these mocked apps are in INSTALLED_APPS
mocked_apps.return_value = [
feature_app, config_app_disabled1, config_app_disabled2]

app_registration.configure_cms_apps([feature_app])

self.assertFalse(feature_app.cms_app.configure_app.called)

@patch.object(apps, 'get_app_configs')
def test_doesnt_raise_exception_if_not_cms_app(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one line

self, mocked_apps
):
# Set up app with name djangocms_feature_x that has a cms feature
feature_app = Mock(spec=AppConfig)
feature_app.name = 'djangocms_feature_x'
feature_app.cms_app = Mock(spec=['configure_app'])
# Set up non cms app
non_cms_app = Mock(spec=AppConfig)
# Pretend these mocked apps are in INSTALLED_APPS
mocked_apps.return_value = [feature_app, non_cms_app]

try:
app_registration.configure_cms_apps([feature_app])
except AttributeError:
self.fail("Exception raised when cms app config not defined")

@patch.object(apps, 'get_app_configs')
def test_runs_configure_app_method_for_correct_apps_when_multiple_apps(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here is ok

self, mocked_apps
):
# Set up app with name djangocms_feature_x that has a cms feature
feature_app_x = Mock(spec=AppConfig)
feature_app_x.name = 'djangocms_feature_x'
feature_app_x.cms_app = Mock(spec=['configure_app'])
# Set up app with name djangocms_feature_y that has a cms feature
feature_app_y = Mock(spec=AppConfig)
feature_app_y.name = 'djangocms_feature_y'
feature_app_y.cms_app = Mock(spec=['configure_app'])
# Set up apps that makes use of djangocms_feature_x
config_app_x = Mock(spec=AppConfig)
config_app_x.cms_app = Mock(
spec=['djangocms_feature_x_enabled'])
config_app_x.cms_app.djangocms_feature_x_enabled = True
# Set up app that makes use of djangocms_feature_y
config_app_y = Mock(spec=AppConfig)
config_app_y.cms_app = Mock(
spec=['djangocms_feature_y_enabled'])
conf F438 ig_app_y.cms_app.djangocms_feature_y_enabled = True
# Set up app that makes use of feature x & y
config_app_xy = Mock(spec=AppConfig)
config_app_xy.cms_app = Mock(
spec=['djangocms_feature_x_enabled',
'djangocms_feature_y_enabled']
)
config_app_xy.cms_app.djangocms_feature_x_enabled = True
config_app_xy.cms_app.djangocms_feature_y_enabled = True
# Set up non cms app
non_cms_app = Mock(spec=AppConfig)
# Pretend these mocked apps are in INSTALLED_APPS
mocked_apps.return_value = [
feature_app_x, non_cms_app, config_app_xy, config_app_y,
config_app_x, feature_app_y]

app_registration.configure_cms_apps(
[feature_app_x, feature_app_y])

# Assert we configured the 2 apps we expected with feature x
self.assertEqual(
feature_app_x.cms_app.configure_app.call_count, 2)
self.assertEqual(
feature_app_x.cms_app.configure_app.call_args_list[0][0][0],
config_app_xy)
self.assertEqual(
feature_app_x.cms_app.configure_app.call_args_list[1][0][0],
config_app_x)
# Assert we configured the 2 apps we expected with feature y
self.assertEqual(
feature_app_y.cms_app.configure_app.call_count, 2)
self.assertEqual(
feature_app_y.cms_app.configure_app.call_args_list[0][0][0],
config_app_xy)
self.assertEqual(
feature_app_y.cms_app.configure_app.call_args_list[1][0][0],
config_app_y)
26 changes: 26 additions & 0 deletions cms/tests/test_app_registry/app_with_cms_extension/cms_apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from cms.app_registration import CMSAppConfig


def some_function(*args, **kwargs):
# this should not be called by autodiscover
raise Exception('some_function called')


class SomeClass():
# this should not be picked up by autodiscover

def __init__(self, *args, **kwargs):
raise Exception('SomeClass instantiated')


class CMSSomeFeatureConfig(CMSAppConfig):

def configure_app(self, app):
pass


class SomeOtherClass():
# this should not be picked up by autodiscover

def __init__(self, *args, **kwargs):
raise Exception('SomeOtherClass instantiated')
Empty file.
Empty file.
Loading
0