mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-17 20:45:44 +00:00
Merge branch 'master' into matmair/issue6281
This commit is contained in:
2
src/backend/InvenTree/.gitignore
vendored
Normal file
2
src/backend/InvenTree/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
# Files generated during unit testing
|
||||
_testfolder/
|
@ -11,29 +11,32 @@ v304 - 2025-01-25 : https://github.com/inventree/InvenTree/pull/6293
|
||||
- Removes a considerable amount of old auth endpoints
|
||||
- Introduces allauth based REST API
|
||||
|
||||
v303 - 2025-01-20 - https://github.com/inventree/InvenTree/pull/8915
|
||||
v304 - 2025-01-22 : https://github.com/inventree/InvenTree/pull/8940
|
||||
- Adds "category" filter to build list API
|
||||
|
||||
v303 - 2025-01-20 : https://github.com/inventree/InvenTree/pull/8915
|
||||
- Adds "start_date" field to Build model and API endpoints
|
||||
- Adds additional API filtering and sorting options for Build list
|
||||
|
||||
v302 - 2025-01-18 - https://github.com/inventree/InvenTree/pull/8905
|
||||
v302 - 2025-01-18 : https://github.com/inventree/InvenTree/pull/8905
|
||||
- Fix schema definition on the /label/print endpoint
|
||||
|
||||
v301 - 2025-01-14 - https://github.com/inventree/InvenTree/pull/8894
|
||||
v301 - 2025-01-14 : https://github.com/inventree/InvenTree/pull/8894
|
||||
- Remove ui preferences from the API
|
||||
|
||||
v300 - 2025-01-13 - https://github.com/inventree/InvenTree/pull/8886
|
||||
v300 - 2025-01-13 : https://github.com/inventree/InvenTree/pull/8886
|
||||
- Allow null value for 'expiry_date' field introduced in #8867
|
||||
|
||||
v299 - 2025-01-10 - https://github.com/inventree/InvenTree/pull/8867
|
||||
v299 - 2025-01-10 : https://github.com/inventree/InvenTree/pull/8867
|
||||
- Adds 'expiry_date' field to the PurchaseOrderReceive API endpoint
|
||||
- Adds 'default_expiry` field to the PartBriefSerializer, affecting API endpoints which use it
|
||||
|
||||
v298 - 2025-01-07 - https://github.com/inventree/InvenTree/pull/8848
|
||||
v298 - 2025-01-07 : https://github.com/inventree/InvenTree/pull/8848
|
||||
- Adds 'created_by' field to PurchaseOrder API endpoints
|
||||
- Adds 'created_by' field to SalesOrder API endpoints
|
||||
- Adds 'created_by' field to ReturnOrder API endpoints
|
||||
|
||||
v297 - 2024-12-29 - https://github.com/inventree/InvenTree/pull/8438
|
||||
v297 - 2024-12-29 : https://github.com/inventree/InvenTree/pull/8438
|
||||
- Adjustments to the CustomUserState API endpoints and serializers
|
||||
|
||||
v296 - 2024-12-25 : https://github.com/inventree/InvenTree/pull/8732
|
||||
|
@ -13,7 +13,7 @@ from rest_framework.exceptions import ValidationError
|
||||
import build.admin
|
||||
import build.serializers
|
||||
import common.models
|
||||
import part.models
|
||||
import part.models as part_models
|
||||
from build.models import Build, BuildItem, BuildLine
|
||||
from build.status_codes import BuildStatus, BuildStatusGroups
|
||||
from generic.states.api import StatusView
|
||||
@ -77,7 +77,10 @@ class BuildFilter(rest_filters.FilterSet):
|
||||
return queryset
|
||||
|
||||
part = rest_filters.ModelChoiceFilter(
|
||||
queryset=part.models.Part.objects.all(), field_name='part', method='filter_part'
|
||||
queryset=part_models.Part.objects.all(),
|
||||
field_name='part',
|
||||
method='filter_part',
|
||||
label=_('Part'),
|
||||
)
|
||||
|
||||
def filter_part(self, queryset, name, part):
|
||||
@ -94,6 +97,17 @@ class BuildFilter(rest_filters.FilterSet):
|
||||
else:
|
||||
return queryset.filter(part=part)
|
||||
|
||||
category = rest_filters.ModelChoiceFilter(
|
||||
queryset=part_models.PartCategory.objects.all(),
|
||||
method='filter_category',
|
||||
label=_('Category'),
|
||||
)
|
||||
|
||||
def filter_category(self, queryset, name, category):
|
||||
"""Filter by part category (including sub-categories)."""
|
||||
categories = category.get_descendants(include_self=True)
|
||||
return queryset.filter(part__category__in=categories)
|
||||
|
||||
ancestor = rest_filters.ModelChoiceFilter(
|
||||
queryset=Build.objects.all(),
|
||||
label=_('Ancestor Build'),
|
||||
@ -417,7 +431,7 @@ class BuildLineFilter(rest_filters.FilterSet):
|
||||
)
|
||||
|
||||
part = rest_filters.ModelChoiceFilter(
|
||||
queryset=part.models.Part.objects.all(),
|
||||
queryset=part_models.Part.objects.all(),
|
||||
label=_('Part'),
|
||||
field_name='bom_item__sub_part',
|
||||
)
|
||||
@ -729,7 +743,7 @@ class BuildItemFilter(rest_filters.FilterSet):
|
||||
return queryset
|
||||
|
||||
part = rest_filters.ModelChoiceFilter(
|
||||
queryset=part.models.Part.objects.all(),
|
||||
queryset=part_models.Part.objects.all(),
|
||||
label=_('Part'),
|
||||
method='filter_part',
|
||||
field_name='stock_item__part',
|
||||
|
@ -10,7 +10,6 @@ import traceback
|
||||
from importlib.metadata import entry_points
|
||||
from importlib.util import module_from_spec
|
||||
|
||||
from django import template
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import AppRegistryNotReady
|
||||
from django.db.utils import IntegrityError
|
||||
@ -244,35 +243,3 @@ def get_plugins(pkg, baseclass, path=None):
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region templates
|
||||
def render_template(plugin, template_file, context=None):
|
||||
"""Locate and render a template file, available in the global template context."""
|
||||
try:
|
||||
tmp = template.loader.get_template(template_file)
|
||||
except template.TemplateDoesNotExist:
|
||||
logger.exception(
|
||||
"Plugin %s could not locate template '%s'", plugin.slug, template_file
|
||||
)
|
||||
|
||||
return f"""
|
||||
<div class='alert alert-block alert-danger'>
|
||||
Template file <em>{template_file}</em> does not exist.
|
||||
</div>
|
||||
"""
|
||||
|
||||
# Render with the provided context
|
||||
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
|
||||
|
@ -142,7 +142,7 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model):
|
||||
|
||||
def save(self, force_insert=False, force_update=False, *args, **kwargs):
|
||||
"""Extend save method to reload plugins if the 'active' status changes."""
|
||||
reload = kwargs.pop('no_reload', False) # check if no_reload flag is set
|
||||
no_reload = kwargs.pop('no_reload', False) # check if no_reload flag is set
|
||||
|
||||
super().save(force_insert, force_update, *args, **kwargs)
|
||||
|
||||
@ -150,10 +150,10 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model):
|
||||
# Force active if builtin
|
||||
self.active = True
|
||||
|
||||
if not reload and self.active != self.__org_active:
|
||||
if not no_reload and self.active != self.__org_active:
|
||||
if settings.PLUGIN_TESTING:
|
||||
warnings.warn('A reload was triggered', stacklevel=2)
|
||||
registry.reload_plugins()
|
||||
warnings.warn('A plugin registry reload was triggered', stacklevel=2)
|
||||
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
|
||||
|
||||
@admin.display(boolean=True, description=_('Installed'))
|
||||
def is_installed(self) -> bool:
|
||||
|
@ -453,6 +453,8 @@ class PluginsRegistry:
|
||||
|
||||
Args:
|
||||
plugin: Plugin module
|
||||
configs: Plugin configuration dictionary
|
||||
force_reload (bool, optional): Force reload of plugin. Defaults to False.
|
||||
"""
|
||||
from InvenTree import version
|
||||
|
||||
@ -485,6 +487,7 @@ class PluginsRegistry:
|
||||
|
||||
# Check if this is a 'builtin' plugin
|
||||
builtin = plugin.check_is_builtin()
|
||||
sample = plugin.check_is_sample()
|
||||
|
||||
package_name = None
|
||||
|
||||
@ -510,11 +513,37 @@ class PluginsRegistry:
|
||||
# Initialize package - we can be sure that an admin has activated the plugin
|
||||
logger.debug('Loading plugin `%s`', plg_name)
|
||||
|
||||
# If this is a third-party plugin, reload the source module
|
||||
# This is required to ensure that separate processes are using the same code
|
||||
if not builtin and not sample:
|
||||
plugin_name = plugin.__name__
|
||||
module_name = plugin.__module__
|
||||
|
||||
if plugin_module := sys.modules.get(module_name):
|
||||
logger.debug('Reloading plugin `%s`', plg_name)
|
||||
# Reload the module
|
||||
try:
|
||||
importlib.reload(plugin_module)
|
||||
plugin = getattr(plugin_module, plugin_name)
|
||||
except ModuleNotFoundError:
|
||||
# No module found - try to import it directly
|
||||
try:
|
||||
raw_module = _load_source(
|
||||
module_name, plugin_module.__file__
|
||||
)
|
||||
plugin = getattr(raw_module, plugin_name)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception('Failed to reload plugin `%s`', plg_name)
|
||||
|
||||
try:
|
||||
t_start = time.time()
|
||||
plg_i: InvenTreePlugin = plugin()
|
||||
dt = time.time() - t_start
|
||||
logger.debug('Loaded plugin `%s` in %.3fs', plg_name, dt)
|
||||
except ModuleNotFoundError as e:
|
||||
raise e
|
||||
except Exception as error:
|
||||
handle_error(
|
||||
error, log_name='init'
|
||||
@ -745,7 +774,7 @@ class PluginsRegistry:
|
||||
|
||||
if old_hash != self.registry_hash:
|
||||
try:
|
||||
logger.debug(
|
||||
logger.info(
|
||||
'Updating plugin registry hash: %s', str(self.registry_hash)
|
||||
)
|
||||
set_global_setting(
|
||||
@ -839,11 +868,16 @@ def _load_source(modname, filename):
|
||||
|
||||
See https://docs.python.org/3/whatsnew/3.12.html#imp
|
||||
"""
|
||||
loader = importlib.machinery.SourceFileLoader(modname, filename)
|
||||
spec = importlib.util.spec_from_file_location(modname, filename, loader=loader)
|
||||
if modname in sys.modules:
|
||||
del sys.modules[modname]
|
||||
|
||||
# loader = importlib.machinery.SourceFileLoader(modname, filename)
|
||||
spec = importlib.util.spec_from_file_location(modname, filename) # , loader=loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
# The module is always executed and not cached in sys.modules.
|
||||
# Uncomment the following line to cache the module.
|
||||
# sys.modules[module.__name__] = module
|
||||
loader.exec_module(module)
|
||||
|
||||
sys.modules[module.__name__] = module
|
||||
|
||||
if spec.loader:
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
return module
|
||||
|
@ -205,7 +205,7 @@ class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
|
||||
|
||||
plg_inactive.active = True
|
||||
plg_inactive.save()
|
||||
self.assertEqual(cm.warning.args[0], 'A reload was triggered')
|
||||
self.assertEqual(cm.warning.args[0], 'A plugin registry reload was triggered')
|
||||
|
||||
def test_check_plugin(self):
|
||||
"""Test check_plugin function."""
|
||||
|
@ -1,26 +0,0 @@
|
||||
"""Unit tests for helpers.py."""
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from .helpers import render_template
|
||||
|
||||
|
||||
class HelperTests(TestCase):
|
||||
"""Tests for helpers."""
|
||||
|
||||
def test_render_template(self):
|
||||
"""Check if render_template helper works."""
|
||||
|
||||
class ErrorSource:
|
||||
slug = 'sampleplg'
|
||||
|
||||
# working sample
|
||||
response = render_template(ErrorSource(), 'sample/sample.html', {'abc': 123})
|
||||
self.assertEqual(response, '<h1>123</h1>\n')
|
||||
|
||||
# Wrong sample
|
||||
response = render_template(
|
||||
ErrorSource(), 'sample/wrongsample.html', {'abc': 123}
|
||||
)
|
||||
self.assertIn('lert alert-block alert-danger', response)
|
||||
self.assertIn('Template file <em>sample/wrongsample.html</em>', response)
|
@ -4,9 +4,11 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import textwrap
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
@ -18,6 +20,9 @@ from plugin.samples.integration.another_sample import (
|
||||
)
|
||||
from plugin.samples.integration.sample import SampleIntegrationPlugin
|
||||
|
||||
# Directory for testing plugins during CI
|
||||
PLUGIN_TEST_DIR = '_testfolder/test_plugins'
|
||||
|
||||
|
||||
class PluginTagTests(TestCase):
|
||||
"""Tests for the plugin extras."""
|
||||
@ -287,3 +292,111 @@ class RegistryTests(TestCase):
|
||||
self.assertEqual(
|
||||
registry.errors.get('init')[0]['broken_sample'], "'This is a dummy error'"
|
||||
)
|
||||
|
||||
@override_settings(PLUGIN_TESTING=True, PLUGIN_TESTING_SETUP=True)
|
||||
@patch.dict(os.environ, {'INVENTREE_PLUGIN_TEST_DIR': PLUGIN_TEST_DIR})
|
||||
def test_registry_reload(self):
|
||||
"""Test that the registry correctly reloads plugin modules.
|
||||
|
||||
- Create a simple plugin which we can change the version
|
||||
- Ensure that the "hash" of the plugin registry changes
|
||||
"""
|
||||
dummy_file = os.path.join(PLUGIN_TEST_DIR, 'dummy_ci_plugin.py')
|
||||
|
||||
# Ensure the plugin dir exists
|
||||
os.makedirs(PLUGIN_TEST_DIR, exist_ok=True)
|
||||
|
||||
# Create an __init__.py file
|
||||
init_file = os.path.join(PLUGIN_TEST_DIR, '__init__.py')
|
||||
if not os.path.exists(init_file):
|
||||
with open(os.path.join(init_file), 'w', encoding='utf-8') as f:
|
||||
f.write('')
|
||||
|
||||
def plugin_content(version):
|
||||
"""Return the content of the plugin file."""
|
||||
content = f"""
|
||||
from plugin import InvenTreePlugin
|
||||
|
||||
PLG_VERSION = "{version}"
|
||||
|
||||
print(">>> LOADING DUMMY PLUGIN v" + PLG_VERSION + " <<<")
|
||||
|
||||
class DummyCIPlugin(InvenTreePlugin):
|
||||
|
||||
NAME = "DummyCIPlugin"
|
||||
SLUG = "dummyci"
|
||||
TITLE = "Dummy plugin for CI testing"
|
||||
|
||||
VERSION = PLG_VERSION
|
||||
|
||||
"""
|
||||
|
||||
return textwrap.dedent(content)
|
||||
|
||||
def create_plugin_file(
|
||||
version: str, enabled: bool = True, reload: bool = True
|
||||
) -> str:
|
||||
"""Create a plugin file with the given version.
|
||||
|
||||
Arguments:
|
||||
version: The version string to use for the plugin file
|
||||
enabled: Whether the plugin should be enabled or not
|
||||
|
||||
Returns:
|
||||
str: The plugin registry hash
|
||||
"""
|
||||
import time
|
||||
|
||||
content = plugin_content(version)
|
||||
|
||||
with open(dummy_file, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
# Wait for the file to be written
|
||||
time.sleep(2)
|
||||
|
||||
if reload:
|
||||
# Ensure the plugin is activated
|
||||
registry.set_plugin_state('dummyci', enabled)
|
||||
registry.reload_plugins(
|
||||
full_reload=True, collect=True, force_reload=True
|
||||
)
|
||||
|
||||
registry.update_plugin_hash()
|
||||
|
||||
return registry.registry_hash
|
||||
|
||||
# Initial hash, with plugin disabled
|
||||
hash_disabled = create_plugin_file('0.0.1', enabled=False, reload=False)
|
||||
|
||||
# Perform initial registry reload
|
||||
registry.reload_plugins(full_reload=True, collect=True, force_reload=True)
|
||||
|
||||
# Start plugin in known state
|
||||
registry.set_plugin_state('dummyci', False)
|
||||
|
||||
hash_disabled = create_plugin_file('0.0.1', enabled=False)
|
||||
|
||||
# Enable the plugin
|
||||
hash_enabled = create_plugin_file('0.1.0', enabled=True)
|
||||
|
||||
# Hash must be different!
|
||||
self.assertNotEqual(hash_disabled, hash_enabled)
|
||||
|
||||
plugin_hash = hash_enabled
|
||||
|
||||
for v in ['0.1.1', '7.1.2', '1.2.1', '4.0.1']:
|
||||
h = create_plugin_file(v, enabled=True)
|
||||
self.assertNotEqual(plugin_hash, h)
|
||||
plugin_hash = h
|
||||
|
||||
# Revert back to original 'version'
|
||||
h = create_plugin_file('0.1.0', enabled=True)
|
||||
self.assertEqual(hash_enabled, h)
|
||||
|
||||
# Disable the plugin
|
||||
h = create_plugin_file('0.0.1', enabled=False)
|
||||
self.assertEqual(hash_disabled, h)
|
||||
|
||||
# Finally, ensure that the plugin file is removed after testing
|
||||
os.remove(dummy_file)
|
||||
|
Reference in New Issue
Block a user