2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 03:26:45 +00:00
Oliver 2b9816d1a3
[Plugin] Enhanced custom validation (#6410)
* Use registry.get_plugin()

- Instead of registry.plugins.get()
- get_plugin checks registry hash
- performs registry reload if necessary

* Add PluginValidationMixin class

- Allows the entire model to be validated via plugins
- Called on model.full_clean()
- Called on model.save()

* Update Validation sample plugin

* Fix for InvenTreeTree models

* Refactor build.models

- Expose models to plugin validation

* Update stock.models

* Update more models

- common.models
- company.models

* Update more models

- label.models
- order.models
- part.models

* More model updates

* Update docs

* Fix for potential plugin edge case

- plugin slug is globally unique
- do not use get_or_create with two lookup fields
- will throw an IntegrityError if you change the name of a plugin

* Inherit DiffMixin into PluginValidationMixin

- Allows us to pass model diffs through to validation
- Plugins can validate based on what has *changed*

* Update documentation

* Add get_plugin_config helper function

* Bug fix

* Bug fix

* Update plugin hash when calling set_plugin_state

* Working on unit testing

* More unit testing

* Move get_plugin_config into registry.py

* Move extract_int into InvenTree.helpers

* Fix log formatting

* Update model definitions

- Ensure there are no changes to the migrations

* Comment out format line

* Fix access to get_plugin_config

* Fix tests for SimpleActionPlugin

* More unit test fixes
2024-02-06 22:00:22 +11:00

473 lines
15 KiB
Python

"""Unit tests for base mixins for plugins."""
import os
from django.conf import settings
from django.test import TestCase
from django.urls import include, path, re_path, reverse
from error_report.models import Error
from InvenTree.unit_test import InvenTreeTestCase
from plugin import InvenTreePlugin
from plugin.base.integration.mixins import PanelMixin
from plugin.helpers import MixinNotImplementedError
from plugin.mixins import (
APICallMixin,
AppMixin,
NavigationMixin,
SettingsMixin,
UrlsMixin,
)
from plugin.registry import registry
from plugin.urls import PLUGIN_BASE
class BaseMixinDefinition:
"""Mixin to test the meta functions of all mixins."""
def test_mixin_name(self):
"""Test that the mixin registers itseld correctly."""
# mixin name
self.assertIn(
self.MIXIN_NAME,
{item['key'] for item in self.mixin.registered_mixins.values()},
)
# human name
self.assertIn(
self.MIXIN_HUMAN_NAME,
{item['human_name'] for item in self.mixin.registered_mixins.values()},
)
class SettingsMixinTest(BaseMixinDefinition, InvenTreeTestCase):
"""Tests for SettingsMixin."""
MIXIN_HUMAN_NAME = 'Settings'
MIXIN_NAME = 'settings'
MIXIN_ENABLE_CHECK = 'has_settings'
TEST_SETTINGS = {'SETTING1': {'default': '123'}}
def setUp(self):
"""Setup for all tests."""
class SettingsCls(SettingsMixin, InvenTreePlugin):
SETTINGS = self.TEST_SETTINGS
self.mixin = SettingsCls()
class NoSettingsCls(SettingsMixin, InvenTreePlugin):
pass
self.mixin_nothing = NoSettingsCls()
super().setUp()
def test_function(self):
"""Test that the mixin functions."""
# settings variable
self.assertEqual(self.mixin.settings, self.TEST_SETTINGS)
# calling settings
# not existing
self.assertEqual(self.mixin.get_setting('ABCD'), '')
self.assertEqual(self.mixin_nothing.get_setting('ABCD'), '')
# right setting
self.mixin.set_setting('SETTING1', '12345', self.user)
self.assertEqual(self.mixin.get_setting('SETTING1'), '12345')
# no setting
self.assertEqual(self.mixin_nothing.get_setting(''), '')
class UrlsMixinTest(BaseMixinDefinition, TestCase):
"""Tests for UrlsMixin."""
MIXIN_HUMAN_NAME = 'URLs'
MIXIN_NAME = 'urls'
MIXIN_ENABLE_CHECK = 'has_urls'
def setUp(self):
"""Setup for all tests."""
class UrlsCls(UrlsMixin, InvenTreePlugin):
def test():
return 'ccc'
URLS = [path('testpath', test, name='test')]
self.mixin = UrlsCls()
class NoUrlsCls(UrlsMixin, InvenTreePlugin):
pass
self.mixin_nothing = NoUrlsCls()
def test_function(self):
"""Test that the mixin functions."""
plg_name = self.mixin.plugin_name()
# base_url
target_url = f'{PLUGIN_BASE}/{plg_name}/'
self.assertEqual(self.mixin.base_url, target_url)
# urlpattern
target_pattern = re_path(
f'^{plg_name}/', include((self.mixin.urls, plg_name)), name=plg_name
)
self.assertEqual(
self.mixin.urlpatterns.reverse_dict, target_pattern.reverse_dict
)
# resolve the view
self.assertEqual(self.mixin.urlpatterns.resolve('/testpath').func(), 'ccc')
self.assertEqual(self.mixin.urlpatterns.reverse('test'), 'testpath')
# no url
self.assertIsNone(self.mixin_nothing.urls)
self.assertIsNone(self.mixin_nothing.urlpatterns)
# internal name
self.assertEqual(self.mixin.internal_name, f'plugin:{self.mixin.slug}:')
class AppMixinTest(BaseMixinDefinition, TestCase):
"""Tests for AppMixin."""
MIXIN_HUMAN_NAME = 'App registration'
MIXIN_NAME = 'app'
MIXIN_ENABLE_CHECK = 'has_app'
def setUp(self):
"""Setup for all tests."""
class TestCls(AppMixin, InvenTreePlugin):
pass
self.mixin = TestCls()
def test_function(self):
"""Test that the sample plugin registers in settings."""
self.assertIn('plugin.samples.integration', settings.INSTALLED_APPS)
class NavigationMixinTest(BaseMixinDefinition, TestCase):
"""Tests for NavigationMixin."""
MIXIN_HUMAN_NAME = 'Navigation Links'
MIXIN_NAME = 'navigation'
MIXIN_ENABLE_CHECK = 'has_naviation'
def setUp(self):
"""Setup for all tests."""
class NavigationCls(NavigationMixin, InvenTreePlugin):
NAVIGATION = [{'name': 'aa', 'link': 'plugin:test:test_view'}]
NAVIGATION_TAB_NAME = 'abcd1'
self.mixin = NavigationCls()
class NothingNavigationCls(NavigationMixin, InvenTreePlugin):
pass
self.nothing_mixin = NothingNavigationCls()
def test_function(self):
"""Test that a correct configuration functions."""
# check right configuration
self.assertEqual(
self.mixin.navigation, [{'name': 'aa', 'link': 'plugin:test:test_view'}]
)
# navigation name
self.assertEqual(self.mixin.navigation_name, 'abcd1')
self.assertEqual(self.nothing_mixin.navigation_name, '')
def test_fail(self):
"""Test that wrong links fail."""
with self.assertRaises(NotImplementedError):
class NavigationCls(NavigationMixin, InvenTreePlugin):
NAVIGATION = ['aa', 'aa']
NavigationCls()
class APICallMixinTest(BaseMixinDefinition, TestCase):
"""Tests for APICallMixin."""
MIXIN_HUMAN_NAME = 'API calls'
MIXIN_NAME = 'api_call'
MIXIN_ENABLE_CHECK = 'has_api_call'
def setUp(self):
"""Setup for all tests."""
class MixinCls(APICallMixin, SettingsMixin, InvenTreePlugin):
NAME = 'Sample API Caller'
SETTINGS = {
'API_TOKEN': {'name': 'API Token', 'protected': True},
'API_URL': {
'name': 'External URL',
'description': 'Where is your API located?',
'default': 'https://api.github.com',
},
}
API_URL_SETTING = 'API_URL'
API_TOKEN_SETTING = 'API_TOKEN'
@property
def api_url(self):
"""Override API URL for this test."""
return 'https://api.github.com'
def get_external_url(self, simple: bool = True):
"""Returns data from the sample endpoint."""
return self.api_call('orgs/inventree', simple_response=simple)
self.mixin = MixinCls()
# If running in github workflow, make use of GITHUB_TOKEN
if settings.TESTING:
token = os.getenv('GITHUB_TOKEN', None)
if token:
self.mixin.set_setting('API_TOKEN', token)
class WrongCLS(APICallMixin, InvenTreePlugin):
pass
self.mixin_wrong = WrongCLS()
class WrongCLS2(APICallMixin, InvenTreePlugin):
API_URL_SETTING = 'test'
self.mixin_wrong2 = WrongCLS2()
def test_base_setup(self):
"""Test that the base settings work."""
# check init
self.assertTrue(self.mixin.has_api_call)
# api_url
self.assertEqual('https://api.github.com', self.mixin.api_url)
# api_headers
headers = self.mixin.api_headers
self.assertEqual(headers['Content-Type'], 'application/json')
def test_args(self):
"""Test that building up args work."""
# api_build_url_args
# 1 arg
result = self.mixin.api_build_url_args({'a': 'b'})
self.assertEqual(result, '?a=b')
# more args
result = self.mixin.api_build_url_args({'a': 'b', 'c': 'd'})
self.assertEqual(result, '?a=b&c=d')
# list args
result = self.mixin.api_build_url_args({'a': 'b', 'c': ['d', 'e', 'f']})
self.assertEqual(result, '?a=b&c=d,e,f')
def test_api_call(self):
"""Test that api calls work."""
# api_call
result = self.mixin.get_external_url()
self.assertTrue(result)
for key in ['login', 'email', 'name', 'twitter_username']:
self.assertIn(key, result)
# api_call without json conversion
result = self.mixin.get_external_url(False)
self.assertTrue(result)
self.assertEqual(result.reason, 'OK')
# api_call with post and data
result = self.mixin.api_call(
'https://reqres.in/api/users/',
json={'name': 'morpheus', 'job': 'leader'},
method='POST',
endpoint_is_url=True,
)
self.assertTrue(result)
self.assertEqual(result['name'], 'morpheus')
# api_call with endpoint with leading slash
result = self.mixin.api_call('/orgs/inventree', simple_response=False)
self.assertTrue(result)
self.assertEqual(result.reason, 'OK')
# api_call with filter
result = self.mixin.api_call(
'repos/inventree/InvenTree/stargazers', url_args={'page': '2'}
)
self.assertTrue(result)
def test_function_errors(self):
"""Test function errors."""
# wrongly defined plugins should not load
with self.assertRaises(MixinNotImplementedError):
self.mixin_wrong.has_api_call()
# cover wrong token setting
with self.assertRaises(MixinNotImplementedError):
self.mixin_wrong2.has_api_call()
# Too many data arguments
with self.assertRaises(ValueError):
self.mixin.api_call(
'https://reqres.in/api/users/', json={'a': 1}, data={'a': 1}
)
# Sending a request with a wrong data format should result in 40
result = self.mixin.api_call(
'https://reqres.in/api/users/',
data={'name': 'morpheus', 'job': 'leader'},
method='POST',
endpoint_is_url=True,
simple_response=False,
)
self.assertEqual(result.status_code, 400)
self.assertIn('Bad Request', str(result.content))
class PanelMixinTests(InvenTreeTestCase):
"""Test that the PanelMixin plugin operates correctly."""
fixtures = ['category', 'part', 'location', 'stock']
roles = 'all'
def test_installed(self):
"""Test that the sample panel plugin is installed."""
plugins = registry.with_mixin('panel')
self.assertTrue(len(plugins) == 0)
# Now enable the plugin
registry.set_plugin_state('samplepanel', True)
plugins = registry.with_mixin('panel')
self.assertIn('samplepanel', [p.slug for p in plugins])
# Find 'inactive' plugins (should be None)
plugins = registry.with_mixin('panel', active=False)
self.assertEqual(len(plugins), 0)
def test_disabled(self):
"""Test that the panels *do not load* if the plugin is not enabled."""
plugin = registry.get_plugin('samplepanel')
plugin.set_setting('ENABLE_HELLO_WORLD', True)
plugin.set_setting('ENABLE_BROKEN_PANEL', True)
# Ensure that the plugin is *not* enabled
config = plugin.plugin_config()
self.assertFalse(config.active)
# Load some pages, ensure that the panel content is *not* loaded
for url in [
reverse('part-detail', kwargs={'pk': 1}),
reverse('stock-item-detail', kwargs={'pk': 2}),
reverse('stock-location-detail', kwargs={'pk': 1}),
]:
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
# Test that these panels have *not* been loaded
self.assertNotIn('No Content', str(response.content))
self.assertNotIn('Hello world', str(response.content))
self.assertNotIn('Custom Part Panel', str(response.content))
def test_enabled(self):
"""Test that the panels *do* load if the plugin is enabled."""
plugin = registry.get_plugin('samplepanel')
self.assertEqual(len(registry.with_mixin('panel', active=True)), 0)
# Ensure that the plugin is enabled
config = plugin.plugin_config()
config.active = True
config.save()
self.assertTrue(config.active)
self.assertEqual(len(registry.with_mixin('panel', active=True)), 1)
# Load some pages, ensure that the panel content is *not* loaded
urls = [
reverse('part-detail', kwargs={'pk': 1}),
reverse('stock-item-detail', kwargs={'pk': 2}),
reverse('stock-location-detail', kwargs={'pk': 2}),
]
plugin.set_setting('ENABLE_HELLO_WORLD', False)
plugin.set_setting('ENABLE_BROKEN_PANEL', False)
for url in urls:
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertIn('No Content', str(response.content))
# This panel is disabled by plugin setting
self.assertNotIn('Hello world!', str(response.content))
# This panel is only active for the "Part" view
if url == urls[0]:
self.assertIn('Custom Part Panel', str(response.content))
else:
self.assertNotIn('Custom Part Panel', str(response.content))
# Enable the 'Hello World' panel
plugin.set_setting('ENABLE_HELLO_WORLD', True)
for url in urls:
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertIn('Hello world!', str(response.content))
# The 'Custom Part' panel should still be there, too
if url == urls[0]:
self.assertIn('Custom Part Panel', str(response.content))
else:
self.assertNotIn('Custom Part Panel', str(response.content))
# Enable the 'broken panel' setting - this will cause all panels to not render
plugin.set_setting('ENABLE_BROKEN_PANEL', True)
n_errors = Error.objects.count()
for url in urls:
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
# No custom panels should have been loaded
self.assertNotIn('No Content', str(response.content))
self.assertNotIn('Hello world!', str(response.content))
self.assertNotIn('Broken Panel', str(response.content))
self.assertNotIn('Custom Part Panel', str(response.content))
# Assert that each request threw an error
self.assertEqual(Error.objects.count(), n_errors + len(urls))
def test_mixin(self):
"""Test that ImplementationError is raised."""
with self.assertRaises(MixinNotImplementedError):
class Wrong(PanelMixin, InvenTreePlugin):
pass
plugin = Wrong()
plugin.get_custom_panels('abc', 'abc')