From 9446702d78bc34900ebb338f34f79c032467463a Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 19 May 2022 09:36:14 +1000 Subject: [PATCH 1/9] Skip plugin loading for various database admin functions --- InvenTree/InvenTree/ready.py | 3 ++- InvenTree/plugin/apps.py | 10 ++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/InvenTree/InvenTree/ready.py b/InvenTree/InvenTree/ready.py index 9f5ad0ea49..e93972cf2e 100644 --- a/InvenTree/InvenTree/ready.py +++ b/InvenTree/InvenTree/ready.py @@ -39,7 +39,8 @@ def canAppAccessDatabase(allow_test=False): 'createsuperuser', 'wait_for_db', 'prerender', - 'rebuild', + 'rebuild_models', + 'rebuild_thumbnails', 'collectstatic', 'makemessages', 'compilemessages', diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py index 233f037ec7..a176612fb6 100644 --- a/InvenTree/plugin/apps.py +++ b/InvenTree/plugin/apps.py @@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _ from maintenance_mode.core import set_maintenance_mode -from InvenTree.ready import isImportingData +from InvenTree.ready import canAppAccessDatabase from plugin import registry from plugin.helpers import check_git_version, log_error @@ -20,9 +20,8 @@ class PluginAppConfig(AppConfig): def ready(self): if settings.PLUGINS_ENABLED: - - if isImportingData(): # pragma: no cover - logger.info('Skipping plugin loading for data import') + if not canAppAccessDatabase(allow_test=True): + logger.info("Skipping plugin loading sequence") else: logger.info('Loading InvenTree plugins') @@ -48,3 +47,6 @@ class PluginAppConfig(AppConfig): 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 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") \ No newline at end of file From 7d9690b974263ba499d026eabee504b5bd6cb8ac Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 19 May 2022 09:53:12 +1000 Subject: [PATCH 2/9] Add logging message when plugin fails to render custom panels --- InvenTree/plugin/views.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/InvenTree/plugin/views.py b/InvenTree/plugin/views.py index 8d28872695..9b12ef12fe 100644 --- a/InvenTree/plugin/views.py +++ b/InvenTree/plugin/views.py @@ -1,3 +1,4 @@ +import logging import sys import traceback @@ -9,6 +10,9 @@ from error_report.models import Error from plugin.registry import registry +logger = logging.getLogger('inventree') + + class InvenTreePluginViewMixin: """ Custom view mixin which adds context data to the view, @@ -42,6 +46,8 @@ class InvenTreePluginViewMixin: 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 def get_context_data(self, **kwargs): From 14b60cdedcde7623fd15e4471ead66a5febe9386 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 19 May 2022 10:03:44 +1000 Subject: [PATCH 3/9] Custom panel content gets passed through the templating engine --- InvenTree/plugin/base/integration/mixins.py | 9 ++++++++- InvenTree/plugin/helpers.py | 11 +++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/InvenTree/plugin/base/integration/mixins.py b/InvenTree/plugin/base/integration/mixins.py index 64de5df22b..c347d6c406 100644 --- a/InvenTree/plugin/base/integration/mixins.py +++ b/InvenTree/plugin/base/integration/mixins.py @@ -11,7 +11,8 @@ from django.db.utils import OperationalError, ProgrammingError 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.registry import registry from plugin.urls import PLUGIN_BASE @@ -578,10 +579,16 @@ class PanelMixin: if content_template: # Render content template to HTML 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: # Render javascript template to HTML 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 required_keys = ['title', 'content'] diff --git a/InvenTree/plugin/helpers.py b/InvenTree/plugin/helpers.py index 1217fa4d47..90ffe61478 100644 --- a/InvenTree/plugin/helpers.py +++ b/InvenTree/plugin/helpers.py @@ -245,4 +245,15 @@ def render_template(plugin, template_file, context=None): html = tmp.render(context) 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 From ebcb9685b56dc293bfca2a07bb8c139e75f39abd Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 19 May 2022 10:04:20 +1000 Subject: [PATCH 4/9] Updates to samplepanel plugin - Enhanced content for "hello world" panel - Add an optional panel which breaks rendering --- .../plugin/samples/event/event_sample.py | 2 +- .../integration/custom_panel_sample.py | 39 +++++++++++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/InvenTree/plugin/samples/event/event_sample.py b/InvenTree/plugin/samples/event/event_sample.py index 5411781e05..bea21c3ea0 100644 --- a/InvenTree/plugin/samples/event/event_sample.py +++ b/InvenTree/plugin/samples/event/event_sample.py @@ -12,7 +12,7 @@ class EventPluginSample(EventMixin, InvenTreePlugin): """ NAME = "EventPlugin" - SLUG = "event" + SLUG = "sampleevent" TITLE = "Triggered Events" def process_event(self, event, *args, **kwargs): diff --git a/InvenTree/plugin/samples/integration/custom_panel_sample.py b/InvenTree/plugin/samples/integration/custom_panel_sample.py index 0203fc4e04..dd84a2a86f 100644 --- a/InvenTree/plugin/samples/integration/custom_panel_sample.py +++ b/InvenTree/plugin/samples/integration/custom_panel_sample.py @@ -15,17 +15,23 @@ class CustomPanelSample(PanelMixin, SettingsMixin, InvenTreePlugin): """ NAME = "CustomPanelExample" - SLUG = "panel" + SLUG = "samplepanel" TITLE = "Custom Panel Example" DESCRIPTION = "An example plugin demonstrating how custom panels can be added to the user interface" VERSION = "0.1" SETTINGS = { 'ENABLE_HELLO_WORLD': { - 'name': 'Hello World', + 'name': 'Enable Hello World', 'description': 'Enable a custom hello world panel on every page', 'default': False, 'validator': bool, + }, + 'ENABLE_BROKEN_PANEL': { + 'name': 'Enable Broken Panel', + 'description': 'Enable a panel with rendering issues', + 'default': False, + 'validator': bool, } } @@ -58,15 +64,42 @@ class CustomPanelSample(PanelMixin, SettingsMixin, InvenTreePlugin): ] if self.get_setting('ENABLE_HELLO_WORLD'): + + # We can use template rendering in the raw content + content = """ + Hello world! +
+
+ We can render custom content using the templating system! +
+
+ + + +
Path{{ request.path }}
User{{ user.username }}
+ """ + panels.append({ # This 'hello world' panel will be displayed on any view which implements custom panels 'title': 'Hello World', 'icon': 'fas fa-boxes', - 'content': 'Hello world!', + 'content': content, 'description': 'A simple panel which renders hello world', '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 if isinstance(view, PartDetail): panels.append({ From 11b21a9cca8a7a9e2f7493caeda3038264196036 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 19 May 2022 11:00:31 +1000 Subject: [PATCH 5/9] Allow registry.with_mixin to filter by active status --- InvenTree/plugin/base/integration/mixins.py | 1 + InvenTree/plugin/registry.py | 10 +++++++++- .../plugin/samples/integration/custom_panel_sample.py | 2 +- InvenTree/plugin/views.py | 2 +- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/InvenTree/plugin/base/integration/mixins.py b/InvenTree/plugin/base/integration/mixins.py index c347d6c406..6977ef3dd9 100644 --- a/InvenTree/plugin/base/integration/mixins.py +++ b/InvenTree/plugin/base/integration/mixins.py @@ -60,6 +60,7 @@ class SettingsMixin: if not plugin: # Cannot find associated plugin model, return + logger.error(f"Plugin configuration not found for plugin '{self.slug}'") return # pragma: no cover PluginSetting.set_setting(key, value, user, plugin=plugin) diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index 3d58634340..d97fa73923 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -243,7 +243,7 @@ class PluginsRegistry: # endregion # 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 """ @@ -251,6 +251,14 @@ class PluginsRegistry: for plugin in self.plugins.values(): 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) return result diff --git a/InvenTree/plugin/samples/integration/custom_panel_sample.py b/InvenTree/plugin/samples/integration/custom_panel_sample.py index dd84a2a86f..3d44bc0c5b 100644 --- a/InvenTree/plugin/samples/integration/custom_panel_sample.py +++ b/InvenTree/plugin/samples/integration/custom_panel_sample.py @@ -58,7 +58,7 @@ class CustomPanelSample(PanelMixin, SettingsMixin, InvenTreePlugin): panels = [ { - # This panel will not be displayed, as it is missing the 'content' key + # Simple panel without any actual content 'title': 'No Content', } ] diff --git a/InvenTree/plugin/views.py b/InvenTree/plugin/views.py index 9b12ef12fe..ad4d54daea 100644 --- a/InvenTree/plugin/views.py +++ b/InvenTree/plugin/views.py @@ -29,7 +29,7 @@ class InvenTreePluginViewMixin: panels = [] - for plug in registry.with_mixin('panel'): + for plug in registry.with_mixin('panel', active=True): try: panels += plug.render_panels(self, self.request, ctx) From 80e3d0970a5b938c0ff3f5459aa745a8bbe42aed Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 19 May 2022 11:28:18 +1000 Subject: [PATCH 6/9] Adds unit tests for the samplepanel plugin --- InvenTree/plugin/apps.py | 2 +- .../plugin/base/integration/test_mixins.py | 158 +++++++++++++++++- InvenTree/plugin/registry.py | 2 +- setup.cfg | 2 +- 4 files changed, 160 insertions(+), 4 deletions(-) diff --git a/InvenTree/plugin/apps.py b/InvenTree/plugin/apps.py index a176612fb6..c0e894fef1 100644 --- a/InvenTree/plugin/apps.py +++ b/InvenTree/plugin/apps.py @@ -49,4 +49,4 @@ class PluginAppConfig(AppConfig): 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") \ No newline at end of file + logger.info("Plugins not enabled - skipping loading sequence") diff --git a/InvenTree/plugin/base/integration/test_mixins.py b/InvenTree/plugin/base/integration/test_mixins.py index ef3f7062e3..4768020bf1 100644 --- a/InvenTree/plugin/base/integration/test_mixins.py +++ b/InvenTree/plugin/base/integration/test_mixins.py @@ -2,14 +2,17 @@ from django.test import TestCase 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.models import Group from plugin import InvenTreePlugin from plugin.mixins import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, APICallMixin from plugin.urls import PLUGIN_BASE from plugin.helpers import MixinNotImplementedError +from plugin.registry import registry + class BaseMixinDefinition: def test_mixin_name(self): @@ -244,3 +247,156 @@ class APICallMixinTest(BaseMixinDefinition, TestCase): # cover wrong token setting with self.assertRaises(MixinNotImplementedError): 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) + + 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)) diff --git a/InvenTree/plugin/registry.py b/InvenTree/plugin/registry.py index d97fa73923..1ec5adb161 100644 --- a/InvenTree/plugin/registry.py +++ b/InvenTree/plugin/registry.py @@ -255,7 +255,7 @@ class PluginsRegistry: if active is not None: # Filter by 'enabled' status config = plugin.plugin_config() - + if config.active != active: continue diff --git a/setup.cfg b/setup.cfg index a483481f5d..0aeaf4d01b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -15,7 +15,7 @@ ignore = N806, # - N812 - lowercase imported as non-lowercase N812, -exclude = .git,__pycache__,*/migrations/*,*/lib/*,*/bin/*,*/media/*,*/static/* +exclude = .git,__pycache__,*/migrations/*,*/lib/*,*/bin/*,*/media/*,*/static/*,InvenTree/plugins/* max-complexity = 20 [coverage:run] From af88f6ec979fae4835ba06deaa8dbe29c3d656f7 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 19 May 2022 11:55:53 +1000 Subject: [PATCH 7/9] python CI: wait for server before continuing --- .github/workflows/qc_checks.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index e73a1e8f98..93b208451b 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -153,6 +153,7 @@ jobs: invoke delete-data -f invoke import-fixtures invoke server -a 127.0.0.1:12345 & + invoke wait - name: Run Tests run: | cd ${{ env.wrapper_name }} From adaec90909d2a081d75f756df0be11898167507b Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 19 May 2022 12:54:07 +1000 Subject: [PATCH 8/9] CI: Allow exchange rate test a few goes --- InvenTree/InvenTree/tests.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index 501eed0834..26b50a0eca 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -1,5 +1,6 @@ import json import os +import time from unittest import mock @@ -406,11 +407,23 @@ class CurrencyTests(TestCase): with self.assertRaises(MissingRate): 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 From 07319731d20e7dbffc106bda2a86517040be0734 Mon Sep 17 00:00:00 2001 From: Oliver Date: Thu, 19 May 2022 13:20:42 +1000 Subject: [PATCH 9/9] Validate that errors get logged --- InvenTree/plugin/base/integration/test_mixins.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/InvenTree/plugin/base/integration/test_mixins.py b/InvenTree/plugin/base/integration/test_mixins.py index 4768020bf1..c1afa39fc2 100644 --- a/InvenTree/plugin/base/integration/test_mixins.py +++ b/InvenTree/plugin/base/integration/test_mixins.py @@ -6,6 +6,8 @@ from django.urls import include, re_path, reverse 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.mixins import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, APICallMixin from plugin.urls import PLUGIN_BASE @@ -391,6 +393,8 @@ class PanelMixinTests(TestCase): # 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) @@ -400,3 +404,6 @@ class PanelMixinTests(TestCase): 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))