Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2022-05-04 12:49:13 +10:00
commit 7e692d91fb
17 changed files with 237 additions and 31 deletions

View File

@ -17,7 +17,7 @@ The HEAD of the "main" or "master" branch of InvenTree represents the current "l
**No pushing to master:** New featues must be submitted as a pull request from a separate branch (one branch per feature). **No pushing to master:** New featues must be submitted as a pull request from a separate branch (one branch per feature).
#### Feature Branches ### Feature Branches
Feature branches should be branched *from* the *master* branch. Feature branches should be branched *from* the *master* branch.
@ -45,7 +45,7 @@ The HEAD of the "stable" branch represents the latest stable release code.
- The bugfix *must* also be cherry picked into the *master* branch. - The bugfix *must* also be cherry picked into the *master* branch.
## Environment ## Environment
#### Target version ### Target version
We are currently targeting: We are currently targeting:
| Name | Minimum version | | Name | Minimum version |
|---|---| |---|---|
@ -65,7 +65,7 @@ pyupgrade `find . -name "*.py"`
django-upgrade --target-version 3.2 `find . -name "*.py"` django-upgrade --target-version 3.2 `find . -name "*.py"`
``` ```
### Credits ## Credits
If you add any new dependencies / libraries, they need to be added to [the docs](https://github.com/inventree/inventree-docs/blob/master/docs/credits.md). Please try to do that as timely as possible. If you add any new dependencies / libraries, they need to be added to [the docs](https://github.com/inventree/inventree-docs/blob/master/docs/credits.md). Please try to do that as timely as possible.
@ -124,4 +124,41 @@ HTML and javascript files are passed through the django templating engine. Trans
{% load i18n %} {% load i18n %}
<span>{% trans "This string will be translated" %} - this string will not!</span> <span>{% trans "This string will be translated" %} - this string will not!</span>
``` ```
## Github use
### Tags
The tags describe issues and PRs in multiple areas:
| Area | Name | Description |
|---|---|---|
| Type Labels | | |
| | bug | Identifies a bug which needs to be addressed |
| | dependency | Relates to a project dependency |
| | duplicate | Duplicate of another issue or PR |
| | enhancement | This is an suggested enhancement or new feature |
| | help wanted | Assistance required |
| | invalid | This issue or PR is considered invalid |
| | inactive | Indicates lack of activity |
| | question | This is a question |
| | roadmap | This is a roadmap feature with no immediate plans for implementation |
| | security | Relates to a security issue |
| | starter | Good issue for a developer new to the project |
| | wontfix | No work will be done against this issue or PR |
| Feature Labels | | |
| | API | Relates to the API |
| | barcode | Barcode scanning and integration |
| | build | Build orders |
| | importer | Data importing and processing |
| | order | Purchase order and sales orders |
| | part | Parts |
| | plugin | Plugin ecosystem |
| | pricing | Pricing functionality |
| | report | Report generation |
| | stock | Stock item management |
| | user interface | User interface |
| Ecosystem Labels | | |
| | demo | Relates to the InvenTree demo server or dataset |
| | docker | Docker / docker-compose |
| | CI | CI / unit testing ecosystem |
| | setup | Relates to the InvenTree setup / installation process |

View File

@ -537,7 +537,7 @@ def extract_serial_numbers(serials, expected_quantity, next_number: int):
# The number of extracted serial numbers must match the expected quantity # The number of extracted serial numbers must match the expected quantity
if not expected_quantity == len(numbers): if not expected_quantity == len(numbers):
raise ValidationError([_("Number of unique serial number ({s}) must match quantity ({q})").format(s=len(numbers), q=expected_quantity)]) raise ValidationError([_("Number of unique serial numbers ({s}) must match quantity ({q})").format(s=len(numbers), q=expected_quantity)])
return numbers return numbers

View File

@ -1177,7 +1177,7 @@ class BuildItem(models.Model):
a = normalize(self.stock_item.quantity) a = normalize(self.stock_item.quantity)
raise ValidationError({ raise ValidationError({
'quantity': _(f'Allocated quantity ({q}) must not execed available stock quantity ({a})') 'quantity': _(f'Allocated quantity ({q}) must not exceed available stock quantity ({a})')
}) })
# Allocated quantity cannot cause the stock item to be over-allocated # Allocated quantity cannot cause the stock item to be over-allocated

View File

@ -387,7 +387,7 @@ class BuildOutputCompleteSerializer(serializers.Serializer):
default=False, default=False,
required=False, required=False,
label=_('Accept Incomplete Allocation'), label=_('Accept Incomplete Allocation'),
help_text=_('Complete ouputs if stock has not been fully allocated'), help_text=_('Complete outputs if stock has not been fully allocated'),
) )
notes = serializers.CharField( notes = serializers.CharField(

View File

@ -35,7 +35,7 @@ def _convert_model(apps, line_item_ref, extra_line_ref, price_ref):
print(f'Done converting line items - now at {OrderExtraLine.objects.all().count()} {extra_line_ref} / {OrderLineItem.objects.all().count()} {line_item_ref} instance(s)') print(f'Done converting line items - now at {OrderExtraLine.objects.all().count()} {extra_line_ref} / {OrderLineItem.objects.all().count()} {line_item_ref} instance(s)')
def _reconvert_model(apps, line_item_ref, extra_line_ref): def _reconvert_model(apps, line_item_ref, extra_line_ref): # pragma: no cover
"""Convert ExtraLine instances back to OrderLineItem instances""" """Convert ExtraLine instances back to OrderLineItem instances"""
OrderLineItem = apps.get_model('order', line_item_ref) OrderLineItem = apps.get_model('order', line_item_ref)
OrderExtraLine = apps.get_model('order', extra_line_ref) OrderExtraLine = apps.get_model('order', extra_line_ref)

View File

@ -35,7 +35,7 @@ class PluginAppConfig(AppConfig):
if InvenTreeSetting.get_setting('PLUGIN_ON_STARTUP', create=False): if InvenTreeSetting.get_setting('PLUGIN_ON_STARTUP', create=False):
# make sure all plugins are installed # make sure all plugins are installed
registry.install_plugin_file() registry.install_plugin_file()
except: except: # pragma: no cover
pass pass
# get plugins and init them # get plugins and init them

View File

@ -69,7 +69,7 @@ class BarcodeMixin:
Default implementation returns None Default implementation returns None
""" """
return None return None # pragma: no cover
def getStockItemByHash(self): def getStockItemByHash(self):
""" """
@ -97,7 +97,7 @@ class BarcodeMixin:
Default implementation returns None Default implementation returns None
""" """
return None return None # pragma: no cover
def renderStockLocation(self, loc): def renderStockLocation(self, loc):
""" """
@ -113,7 +113,7 @@ class BarcodeMixin:
Default implementation returns None Default implementation returns None
""" """
return None return None # pragma: no cover
def renderPart(self, part): def renderPart(self, part):
""" """
@ -143,4 +143,4 @@ class BarcodeMixin:
""" """
Default implementation returns False Default implementation returns False
""" """
return False return False # pragma: no cover

View File

@ -56,7 +56,7 @@ class SettingsMixin:
if not plugin: if not plugin:
# Cannot find associated plugin model, return # Cannot find associated plugin model, return
return return # pragma: no cover
PluginSetting.set_setting(key, value, user, plugin=plugin) PluginSetting.set_setting(key, value, user, plugin=plugin)
@ -171,7 +171,7 @@ class ScheduleMixin:
if Schedule.objects.filter(name=task_name).exists(): if Schedule.objects.filter(name=task_name).exists():
# Scheduled task already exists - continue! # Scheduled task already exists - continue!
continue continue # pragma: no cover
logger.info(f"Adding scheduled task '{task_name}'") logger.info(f"Adding scheduled task '{task_name}'")
@ -209,7 +209,7 @@ class ScheduleMixin:
repeats=task.get('repeats', -1), repeats=task.get('repeats', -1),
) )
except (ProgrammingError, OperationalError): except (ProgrammingError, OperationalError): # pragma: no cover
# Database might not yet be ready # Database might not yet be ready
logger.warning("register_tasks failed, database not ready") logger.warning("register_tasks failed, database not ready")
@ -230,7 +230,7 @@ class ScheduleMixin:
scheduled_task.delete() scheduled_task.delete()
except Schedule.DoesNotExist: except Schedule.DoesNotExist:
pass pass
except (ProgrammingError, OperationalError): except (ProgrammingError, OperationalError): # pragma: no cover
# Database might not yet be ready # Database might not yet be ready
logger.warning("unregister_tasks failed, database not ready") logger.warning("unregister_tasks failed, database not ready")
@ -408,7 +408,7 @@ class LabelPrintingMixin:
""" """
MIXIN_NAME = 'Label printing' MIXIN_NAME = 'Label printing'
def __init__(self): def __init__(self): # pragma: no cover
super().__init__() super().__init__()
self.add_mixin('labels', True, __class__) self.add_mixin('labels', True, __class__)
@ -426,7 +426,7 @@ class LabelPrintingMixin:
""" """
# Unimplemented (to be implemented by the particular plugin class) # Unimplemented (to be implemented by the particular plugin class)
... ... # pragma: no cover
class APICallMixin: class APICallMixin:

View File

@ -75,7 +75,7 @@ def handle_error(error, do_raise: bool = True, do_log: bool = True, log_name: st
path_parts.remove('plugin') path_parts.remove('plugin')
path_parts.pop(0) path_parts.pop(0)
else: else:
path_parts.remove('plugins') path_parts.remove('plugins') # pragma: no cover
package_name = '.'.join(path_parts) package_name = '.'.join(path_parts)
@ -88,7 +88,7 @@ def handle_error(error, do_raise: bool = True, do_log: bool = True, log_name: st
if do_raise: if do_raise:
# do a straight raise if we are playing with enviroment variables at execution time, ignore the broken sample # do a straight raise if we are playing with enviroment variables at execution time, ignore the broken sample
if settings.TESTING_ENV and package_name != 'integration.broken_sample' and isinstance(error, IntegrityError): if settings.TESTING_ENV and package_name != 'integration.broken_sample' and isinstance(error, IntegrityError):
raise error raise error # pragma: no cover
raise IntegrationPluginError(package_name, str(error)) raise IntegrationPluginError(package_name, str(error))
# endregion # endregion
@ -135,7 +135,7 @@ def check_git_version():
except ValueError: # pragma: no cover except ValueError: # pragma: no cover
pass pass
return False return False # pragma: no cover
class GitStatus: class GitStatus:

View File

@ -191,7 +191,7 @@ class IntegrationPluginBase(MixinBase, plugin_base.InvenTreePluginBase):
Path to the plugin Path to the plugin
""" """
if self._is_package: if self._is_package:
return self.__module__ return self.__module__ # pragma: no cover
return pathlib.Path(self.def_path).relative_to(settings.BASE_DIR) return pathlib.Path(self.def_path).relative_to(settings.BASE_DIR)
@property @property

View File

@ -283,7 +283,7 @@ class PluginsRegistry:
if not settings.PLUGIN_TESTING: if not settings.PLUGIN_TESTING:
raise error # pragma: no cover raise error # pragma: no cover
plugin_db_setting = None plugin_db_setting = None
except (IntegrityError) as error: except (IntegrityError) as error: # pragma: no cover
logger.error(f"Error initializing plugin: {error}") logger.error(f"Error initializing plugin: {error}")
# Always activate if testing # Always activate if testing
@ -322,7 +322,7 @@ class PluginsRegistry:
self.plugins[plugin.slug] = plugin self.plugins[plugin.slug] = plugin
else: else:
# save for later reference # save for later reference
self.plugins_inactive[plug_key] = plugin_db_setting self.plugins_inactive[plug_key] = plugin_db_setting # pragma: no cover
def _activate_plugins(self, force_reload=False): def _activate_plugins(self, force_reload=False):
""" """
@ -411,7 +411,7 @@ class PluginsRegistry:
deleted_count += 1 deleted_count += 1
if deleted_count > 0: if deleted_count > 0:
logger.info(f"Removed {deleted_count} old scheduled tasks") logger.info(f"Removed {deleted_count} old scheduled tasks") # pragma: no cover
except (ProgrammingError, OperationalError): except (ProgrammingError, OperationalError):
# Database might not yet be ready # Database might not yet be ready
logger.warning("activate_integration_schedule failed, database not ready") logger.warning("activate_integration_schedule failed, database not ready")

View File

@ -8,11 +8,11 @@ from plugin.mixins import ScheduleMixin, SettingsMixin
# Define some simple tasks to perform # Define some simple tasks to perform
def print_hello(): def print_hello():
print("Hello") print("Hello") # pragma: no cover
def print_world(): def print_world():
print("World") print("World") # pragma: no cover
class ScheduledTaskPlugin(ScheduleMixin, SettingsMixin, IntegrationPluginBase): class ScheduledTaskPlugin(ScheduleMixin, SettingsMixin, IntegrationPluginBase):
@ -36,7 +36,7 @@ class ScheduledTaskPlugin(ScheduleMixin, SettingsMixin, IntegrationPluginBase):
'minutes': 45, 'minutes': 45,
}, },
'world': { 'world': {
'func': 'plugin.samples.integration.scheduled_task.print_hello', 'func': 'plugin.samples.integration.scheduled_task.print_world',
'schedule': 'H', 'schedule': 'H',
}, },
} }
@ -58,3 +58,4 @@ class ScheduledTaskPlugin(ScheduleMixin, SettingsMixin, IntegrationPluginBase):
t_or_f = self.get_setting('T_OR_F') t_or_f = self.get_setting('T_OR_F')
print(f"Called member_func - value is {t_or_f}") print(f"Called member_func - value is {t_or_f}")
return t_or_f

View File

@ -0,0 +1,153 @@
""" Unit tests for scheduled tasks"""
from django.test import TestCase
from plugin import registry, IntegrationPluginBase
from plugin.helpers import MixinImplementationError
from plugin.registry import call_function
from plugin.mixins import ScheduleMixin
class ExampleScheduledTaskPluginTests(TestCase):
""" Tests for provided ScheduledTaskPlugin """
def test_function(self):
"""check if the scheduling works"""
# The plugin should be defined
self.assertIn('schedule', registry.plugins)
plg = registry.plugins['schedule']
self.assertTrue(plg)
# check that the built-in function is running
self.assertEqual(plg.member_func(), False)
# check that the tasks are defined
self.assertEqual(plg.get_task_names(), ['plugin.schedule.member', 'plugin.schedule.hello', 'plugin.schedule.world'])
# register
plg.register_tasks()
# check that schedule was registers
from django_q.models import Schedule
scheduled_plugin_tasks = Schedule.objects.filter(name__istartswith="plugin.")
self.assertEqual(len(scheduled_plugin_tasks), 3)
# delete middle task
# this is to check the system also deals with disappearing tasks
scheduled_plugin_tasks[1].delete()
# there should be one less now
scheduled_plugin_tasks = Schedule.objects.filter(name__istartswith="plugin.")
self.assertEqual(len(scheduled_plugin_tasks), 2)
# test unregistering
plg.unregister_tasks()
scheduled_plugin_tasks = Schedule.objects.filter(name__istartswith="plugin.")
self.assertEqual(len(scheduled_plugin_tasks), 0)
def test_calling(self):
"""check if a function can be called without errors"""
self.assertEqual(call_function('schedule', 'member_func'), False)
class ScheduledTaskPluginTests(TestCase):
""" Tests for ScheduledTaskPluginTests mixin base """
def test_init(self):
"""Check that all MixinImplementationErrors raise"""
class Base(ScheduleMixin, IntegrationPluginBase):
PLUGIN_NAME = 'APlugin'
class NoSchedules(Base):
"""Plugin without schedules"""
pass
with self.assertRaises(MixinImplementationError):
NoSchedules()
class WrongFuncSchedules(Base):
"""
Plugin with broken functions
This plugin is missing a func
"""
SCHEDULED_TASKS = {
'test': {
'schedule': 'I',
'minutes': 30,
},
}
def test(self):
pass # pragma: no cover
with self.assertRaises(MixinImplementationError):
WrongFuncSchedules()
class WrongFuncSchedules1(WrongFuncSchedules):
"""
Plugin with broken functions
This plugin is missing a schedule
"""
SCHEDULED_TASKS = {
'test': {
'func': 'test',
'minutes': 30,
},
}
with self.assertRaises(MixinImplementationError):
WrongFuncSchedules1()
class WrongFuncSchedules2(WrongFuncSchedules):
"""
Plugin with broken functions
This plugin is missing a schedule
"""
SCHEDULED_TASKS = {
'test': {
'func': 'test',
'minutes': 30,
},
}
with self.assertRaises(MixinImplementationError):
WrongFuncSchedules2()
class WrongFuncSchedules3(WrongFuncSchedules):
"""
Plugin with broken functions
This plugin has a broken schedule
"""
SCHEDULED_TASKS = {
'test': {
'func': 'test',
'schedule': 'XX',
'minutes': 30,
},
}
with self.assertRaises(MixinImplementationError):
WrongFuncSchedules3()
class WrongFuncSchedules4(WrongFuncSchedules):
"""
Plugin with broken functions
This plugin is missing a minute marker for its schedule
"""
SCHEDULED_TASKS = {
'test': {
'func': 'test',
'schedule': 'I',
},
}
with self.assertRaises(MixinImplementationError):
WrongFuncSchedules4()

View File

@ -11,6 +11,8 @@ from plugin import IntegrationPluginBase
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.samples.integration.sample import SampleIntegrationPlugin
class BaseMixinDefinition: class BaseMixinDefinition:
def test_mixin_name(self): def test_mixin_name(self):
@ -238,6 +240,7 @@ class IntegrationPluginBaseTests(TestCase):
LICENSE = 'MIT' LICENSE = 'MIT'
self.plugin_name = NameIntegrationPluginBase() self.plugin_name = NameIntegrationPluginBase()
self.plugin_sample = SampleIntegrationPlugin()
def test_action_name(self): def test_action_name(self):
"""check the name definition possibilities""" """check the name definition possibilities"""
@ -246,6 +249,10 @@ class IntegrationPluginBaseTests(TestCase):
self.assertEqual(self.plugin_simple.plugin_name(), 'SimplePlugin') self.assertEqual(self.plugin_simple.plugin_name(), 'SimplePlugin')
self.assertEqual(self.plugin_name.plugin_name(), 'Aplugin') self.assertEqual(self.plugin_name.plugin_name(), 'Aplugin')
# is_sampe
self.assertEqual(self.plugin.is_sample, False)
self.assertEqual(self.plugin_sample.is_sample, True)
# slug # slug
self.assertEqual(self.plugin.slug, '') self.assertEqual(self.plugin.slug, '')
self.assertEqual(self.plugin_simple.slug, 'simpleplugin') self.assertEqual(self.plugin_simple.slug, 'simpleplugin')

View File

@ -31,6 +31,10 @@ class InvenTreePluginTests(TestCase):
self.assertEqual(self.named_plugin.PLUGIN_NAME, 'abc123') self.assertEqual(self.named_plugin.PLUGIN_NAME, 'abc123')
self.assertEqual(self.named_plugin.plugin_name(), 'abc123') self.assertEqual(self.named_plugin.plugin_name(), 'abc123')
def test_basic_is_active(self):
"""check if a basic plugin is active"""
self.assertEqual(self.plugin.is_active(), False)
class PluginTagTests(TestCase): class PluginTagTests(TestCase):
""" Tests for the plugin extras """ """ Tests for the plugin extras """

View File

@ -564,14 +564,14 @@ class Owner(models.Model):
try: try:
owners.append(cls.objects.get(owner_id=user.pk, owner_type=user_type)) owners.append(cls.objects.get(owner_id=user.pk, owner_type=user_type))
except: except: # pragma: no cover
pass pass
for group in user.groups.all(): for group in user.groups.all():
try: try:
owner = cls.objects.get(owner_id=group.pk, owner_type=group_type) owner = cls.objects.get(owner_id=group.pk, owner_type=group_type)
owners.append(owner) owners.append(owner)
except: except: # pragma: no cover
pass pass
return owners return owners

View File

@ -197,6 +197,10 @@ class OwnerModelTest(TestCase):
self.assertTrue(user_as_owner in related_owners) self.assertTrue(user_as_owner in related_owners)
self.assertTrue(group_as_owner in related_owners) self.assertTrue(group_as_owner in related_owners)
# Check owner matching
owners = Owner.get_owners_matching_user(self.user)
self.assertEqual(owners, [user_as_owner, group_as_owner])
# Delete user and verify owner was deleted too # Delete user and verify owner was deleted too
self.user.delete() self.user.delete()
user_as_owner = Owner.get_owner(self.user) user_as_owner = Owner.get_owner(self.user)