2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 11:36:44 +00:00

Merge pull request #3034 from SchrodingersGat/plugin-panels-test

Plugin panels test
This commit is contained in:
Oliver 2022-05-19 14:30:03 +10:00 committed by GitHub
commit 1bff1868fd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 264 additions and 18 deletions

View File

@ -153,6 +153,7 @@ jobs:
invoke delete-data -f invoke delete-data -f
invoke import-fixtures invoke import-fixtures
invoke server -a 127.0.0.1:12345 & invoke server -a 127.0.0.1:12345 &
invoke wait
- name: Run Tests - name: Run Tests
run: | run: |
cd ${{ env.wrapper_name }} cd ${{ env.wrapper_name }}

View File

@ -39,7 +39,8 @@ def canAppAccessDatabase(allow_test=False):
'createsuperuser', 'createsuperuser',
'wait_for_db', 'wait_for_db',
'prerender', 'prerender',
'rebuild', 'rebuild_models',
'rebuild_thumbnails',
'collectstatic', 'collectstatic',
'makemessages', 'makemessages',
'compilemessages', 'compilemessages',

View File

@ -1,5 +1,6 @@
import json import json
import os import os
import time
from unittest import mock from unittest import mock
@ -406,11 +407,23 @@ class CurrencyTests(TestCase):
with self.assertRaises(MissingRate): with self.assertRaises(MissingRate):
convert_money(Money(100, 'AUD'), 'USD') convert_money(Money(100, 'AUD'), 'USD')
InvenTree.tasks.update_exchange_rates() update_successful = False
rates = Rate.objects.all() # Note: the update sometimes fails in CI, let's give it a few chances
for idx in range(10):
InvenTree.tasks.update_exchange_rates()
self.assertEqual(rates.count(), len(currency_codes())) rates = Rate.objects.all()
if rates.count() == len(currency_codes()):
update_successful = True
break
else:
print("Exchange rate update failed - retrying")
time.sleep(1)
self.assertTrue(update_successful)
# Now that we have some exchange rate information, we can perform conversions # Now that we have some exchange rate information, we can perform conversions

View File

@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _
from maintenance_mode.core import set_maintenance_mode from maintenance_mode.core import set_maintenance_mode
from InvenTree.ready import isImportingData from InvenTree.ready import canAppAccessDatabase
from plugin import registry from plugin import registry
from plugin.helpers import check_git_version, log_error from plugin.helpers import check_git_version, log_error
@ -20,9 +20,8 @@ class PluginAppConfig(AppConfig):
def ready(self): def ready(self):
if settings.PLUGINS_ENABLED: if settings.PLUGINS_ENABLED:
if not canAppAccessDatabase(allow_test=True):
if isImportingData(): # pragma: no cover logger.info("Skipping plugin loading sequence")
logger.info('Skipping plugin loading for data import')
else: else:
logger.info('Loading InvenTree plugins') logger.info('Loading InvenTree plugins')
@ -48,3 +47,6 @@ class PluginAppConfig(AppConfig):
registry.git_is_modern = check_git_version() registry.git_is_modern = check_git_version()
if not registry.git_is_modern: # pragma: no cover # simulating old git seems not worth it for coverage if not registry.git_is_modern: # pragma: no cover # simulating old git seems not worth it for coverage
log_error(_('Your enviroment has an outdated git version. This prevents InvenTree from loading plugin details.'), 'load') log_error(_('Your enviroment has an outdated git version. This prevents InvenTree from loading plugin details.'), 'load')
else:
logger.info("Plugins not enabled - skipping loading sequence")

View File

@ -11,7 +11,8 @@ from django.db.utils import OperationalError, ProgrammingError
import InvenTree.helpers import InvenTree.helpers
from plugin.helpers import MixinImplementationError, MixinNotImplementedError, render_template from plugin.helpers import MixinImplementationError, MixinNotImplementedError
from plugin.helpers import render_template, render_text
from plugin.models import PluginConfig, PluginSetting from plugin.models import PluginConfig, PluginSetting
from plugin.registry import registry from plugin.registry import registry
from plugin.urls import PLUGIN_BASE from plugin.urls import PLUGIN_BASE
@ -59,6 +60,7 @@ class SettingsMixin:
if not plugin: if not plugin:
# Cannot find associated plugin model, return # Cannot find associated plugin model, return
logger.error(f"Plugin configuration not found for plugin '{self.slug}'")
return # pragma: no cover return # pragma: no cover
PluginSetting.set_setting(key, value, user, plugin=plugin) PluginSetting.set_setting(key, value, user, plugin=plugin)
@ -578,10 +580,16 @@ class PanelMixin:
if content_template: if content_template:
# Render content template to HTML # Render content template to HTML
panel['content'] = render_template(self, content_template, ctx) panel['content'] = render_template(self, content_template, ctx)
else:
# Render content string to HTML
panel['content'] = render_text(panel.get('content', ''), ctx)
if javascript_template: if javascript_template:
# Render javascript template to HTML # Render javascript template to HTML
panel['javascript'] = render_template(self, javascript_template, ctx) panel['javascript'] = render_template(self, javascript_template, ctx)
else:
# Render javascript string to HTML
panel['javascript'] = render_text(panel.get('javascript', ''), ctx)
# Check for required keys # Check for required keys
required_keys = ['title', 'content'] required_keys = ['title', 'content']

View File

@ -2,14 +2,19 @@
from django.test import TestCase from django.test import TestCase
from django.conf import settings from django.conf import settings
from django.urls import include, re_path from django.urls import include, re_path, reverse
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from error_report.models import Error
from plugin import InvenTreePlugin from plugin import InvenTreePlugin
from plugin.mixins import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, APICallMixin from plugin.mixins import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, APICallMixin
from plugin.urls import PLUGIN_BASE from plugin.urls import PLUGIN_BASE
from plugin.helpers import MixinNotImplementedError from plugin.helpers import MixinNotImplementedError
from plugin.registry import registry
class BaseMixinDefinition: class BaseMixinDefinition:
def test_mixin_name(self): def test_mixin_name(self):
@ -244,3 +249,161 @@ class APICallMixinTest(BaseMixinDefinition, TestCase):
# cover wrong token setting # cover wrong token setting
with self.assertRaises(MixinNotImplementedError): with self.assertRaises(MixinNotImplementedError):
self.mixin_wrong2.has_api_call() self.mixin_wrong2.has_api_call()
class PanelMixinTests(TestCase):
"""Test that the PanelMixin plugin operates correctly"""
fixtures = [
'category',
'part',
'location',
'stock',
]
def setUp(self):
super().setUp()
# Create a user which has all the privelages
user = get_user_model()
self.user = user.objects.create_user(
username='username',
email='user@email.com',
password='password'
)
# Put the user into a group with the correct permissions
group = Group.objects.create(name='mygroup')
self.user.groups.add(group)
# Give the group *all* the permissions!
for rule in group.rule_sets.all():
rule.can_view = True
rule.can_change = True
rule.can_add = True
rule.can_delete = True
rule.save()
self.client.login(username='username', password='password')
def test_installed(self):
"""Test that the sample panel plugin is installed"""
plugins = registry.with_mixin('panel')
self.assertTrue(len(plugins) > 0)
self.assertIn('samplepanel', [p.slug for p in plugins])
plugins = registry.with_mixin('panel', active=True)
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': 1}),
]
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))

View File

@ -245,4 +245,15 @@ def render_template(plugin, template_file, context=None):
html = tmp.render(context) html = tmp.render(context)
return html return html
def render_text(text, context=None):
"""
Locate a raw string with provided context
"""
ctx = template.Context(context)
return template.Template(text).render(ctx)
# endregion # endregion

View File

@ -243,7 +243,7 @@ class PluginsRegistry:
# endregion # endregion
# region registry functions # region registry functions
def with_mixin(self, mixin: str): def with_mixin(self, mixin: str, active=None):
""" """
Returns reference to all plugins that have a specified mixin enabled Returns reference to all plugins that have a specified mixin enabled
""" """
@ -251,6 +251,14 @@ class PluginsRegistry:
for plugin in self.plugins.values(): for plugin in self.plugins.values():
if plugin.mixin_enabled(mixin): if plugin.mixin_enabled(mixin):
if active is not None:
# Filter by 'enabled' status
config = plugin.plugin_config()
if config.active != active:
continue
result.append(plugin) result.append(plugin)
return result return result

View File

@ -12,7 +12,7 @@ class EventPluginSample(EventMixin, InvenTreePlugin):
""" """
NAME = "EventPlugin" NAME = "EventPlugin"
SLUG = "event" SLUG = "sampleevent"
TITLE = "Triggered Events" TITLE = "Triggered Events"
def process_event(self, event, *args, **kwargs): def process_event(self, event, *args, **kwargs):

View File

@ -15,17 +15,23 @@ class CustomPanelSample(PanelMixin, SettingsMixin, InvenTreePlugin):
""" """
NAME = "CustomPanelExample" NAME = "CustomPanelExample"
SLUG = "panel" SLUG = "samplepanel"
TITLE = "Custom Panel Example" TITLE = "Custom Panel Example"
DESCRIPTION = "An example plugin demonstrating how custom panels can be added to the user interface" DESCRIPTION = "An example plugin demonstrating how custom panels can be added to the user interface"
VERSION = "0.1" VERSION = "0.1"
SETTINGS = { SETTINGS = {
'ENABLE_HELLO_WORLD': { 'ENABLE_HELLO_WORLD': {
'name': 'Hello World', 'name': 'Enable Hello World',
'description': 'Enable a custom hello world panel on every page', 'description': 'Enable a custom hello world panel on every page',
'default': False, 'default': False,
'validator': bool, 'validator': bool,
},
'ENABLE_BROKEN_PANEL': {
'name': 'Enable Broken Panel',
'description': 'Enable a panel with rendering issues',
'default': False,
'validator': bool,
} }
} }
@ -52,21 +58,48 @@ class CustomPanelSample(PanelMixin, SettingsMixin, InvenTreePlugin):
panels = [ panels = [
{ {
# This panel will not be displayed, as it is missing the 'content' key # Simple panel without any actual content
'title': 'No Content', 'title': 'No Content',
} }
] ]
if self.get_setting('ENABLE_HELLO_WORLD'): if self.get_setting('ENABLE_HELLO_WORLD'):
# We can use template rendering in the raw content
content = """
<strong>Hello world!</strong>
<hr>
<div class='alert-alert-block alert-info'>
<em>We can render custom content using the templating system!</em>
</div>
<hr>
<table class='table table-striped'>
<tr><td><strong>Path</strong></td><td>{{ request.path }}</tr>
<tr><td><strong>User</strong></td><td>{{ user.username }}</tr>
</table>
"""
panels.append({ panels.append({
# This 'hello world' panel will be displayed on any view which implements custom panels # This 'hello world' panel will be displayed on any view which implements custom panels
'title': 'Hello World', 'title': 'Hello World',
'icon': 'fas fa-boxes', 'icon': 'fas fa-boxes',
'content': '<b>Hello world!</b>', 'content': content,
'description': 'A simple panel which renders hello world', 'description': 'A simple panel which renders hello world',
'javascript': 'console.log("Hello world, from a custom panel!");', 'javascript': 'console.log("Hello world, from a custom panel!");',
}) })
if self.get_setting('ENABLE_BROKEN_PANEL'):
# Enabling this panel will cause panel rendering to break,
# due to the invalid tags
panels.append({
'title': 'Broken Panel',
'icon': 'fas fa-times-circle',
'content': '{% tag_not_loaded %}',
'description': 'This panel is broken',
'javascript': '{% another_bad_tag %}',
})
# This panel will *only* display on the PartDetail view # This panel will *only* display on the PartDetail view
if isinstance(view, PartDetail): if isinstance(view, PartDetail):
panels.append({ panels.append({

View File

@ -1,3 +1,4 @@
import logging
import sys import sys
import traceback import traceback
@ -9,6 +10,9 @@ from error_report.models import Error
from plugin.registry import registry from plugin.registry import registry
logger = logging.getLogger('inventree')
class InvenTreePluginViewMixin: class InvenTreePluginViewMixin:
""" """
Custom view mixin which adds context data to the view, Custom view mixin which adds context data to the view,
@ -25,7 +29,7 @@ class InvenTreePluginViewMixin:
panels = [] panels = []
for plug in registry.with_mixin('panel'): for plug in registry.with_mixin('panel', active=True):
try: try:
panels += plug.render_panels(self, self.request, ctx) panels += plug.render_panels(self, self.request, ctx)
@ -42,6 +46,8 @@ class InvenTreePluginViewMixin:
html=ExceptionReporter(self.request, kind, info, data).get_traceback_html(), html=ExceptionReporter(self.request, kind, info, data).get_traceback_html(),
) )
logger.error(f"Plugin '{plug.slug}' could not render custom panels at '{self.request.path}'")
return panels return panels
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):

View File

@ -15,7 +15,7 @@ ignore =
N806, N806,
# - N812 - lowercase imported as non-lowercase # - N812 - lowercase imported as non-lowercase
N812, N812,
exclude = .git,__pycache__,*/migrations/*,*/lib/*,*/bin/*,*/media/*,*/static/* exclude = .git,__pycache__,*/migrations/*,*/lib/*,*/bin/*,*/media/*,*/static/*,InvenTree/plugins/*
max-complexity = 20 max-complexity = 20
[coverage:run] [coverage:run]