Merge remote-tracking branch 'inventree/master' into triggers

# Conflicts:
#	InvenTree/plugin/mixins/__init__.py
This commit is contained in:
Oliver 2022-01-10 20:34:42 +11:00
commit fde2b03172
11 changed files with 298 additions and 14 deletions

View File

@ -584,7 +584,7 @@ class Build(MPTTModel, ReferenceIndexingMixin):
self.subtractUntrackedStock(user)
# Ensure that there are no longer any BuildItem objects
# which point to thie Build Order
# which point to thisFcan Build Order
self.allocated_stock.all().delete()
# Register an event

View File

@ -248,6 +248,8 @@ class BuildCompleteSerializer(serializers.Serializer):
accept_unallocated = serializers.BooleanField(
label=_('Accept Unallocated'),
help_text=_('Accept that stock items have not been fully allocated to this build order'),
required=False,
default=False,
)
def validate_accept_unallocated(self, value):
@ -262,6 +264,8 @@ class BuildCompleteSerializer(serializers.Serializer):
accept_incomplete = serializers.BooleanField(
label=_('Accept Incomplete'),
help_text=_('Accept that the required number of build outputs have not been completed'),
required=False,
default=False,
)
def validate_accept_incomplete(self, value):
@ -273,6 +277,15 @@ class BuildCompleteSerializer(serializers.Serializer):
return value
def validate(self, data):
build = self.context['build']
if build.incomplete_count > 0:
raise ValidationError(_("Build order has incomplete outputs"))
return data
def save(self):
request = self.context['request']

View File

@ -38,7 +38,7 @@ class BuildAPITest(InvenTreeAPITestCase):
super().setUp()
class BuildCompleteTest(BuildAPITest):
class BuildOutputCompleteTest(BuildAPITest):
"""
Unit testing for the build complete API endpoint
"""
@ -140,6 +140,9 @@ class BuildCompleteTest(BuildAPITest):
Test build order completion
"""
# Initially, build should not be able to be completed
self.assertFalse(self.build.can_complete)
# We start without any outputs assigned against the build
self.assertEqual(self.build.incomplete_outputs.count(), 0)
@ -153,7 +156,7 @@ class BuildCompleteTest(BuildAPITest):
self.assertEqual(self.build.completed, 0)
# We shall complete 4 of these outputs
outputs = self.build.incomplete_outputs[0:4]
outputs = self.build.incomplete_outputs.all()
self.post(
self.url,
@ -165,19 +168,43 @@ class BuildCompleteTest(BuildAPITest):
expected_code=201
)
# There now should be 6 incomplete build outputs remaining
self.assertEqual(self.build.incomplete_outputs.count(), 6)
self.assertEqual(self.build.incomplete_outputs.count(), 0)
# And there should be 4 completed outputs
# And there should be 10 completed outputs
outputs = self.build.complete_outputs
self.assertEqual(outputs.count(), 4)
self.assertEqual(outputs.count(), 10)
for output in outputs:
self.assertFalse(output.is_building)
self.assertEqual(output.build, self.build)
self.build.refresh_from_db()
self.assertEqual(self.build.completed, 40)
self.assertEqual(self.build.completed, 100)
# Try to complete the build (it should fail)
finish_url = reverse('api-build-finish', kwargs={'pk': self.build.pk})
response = self.post(
finish_url,
{},
expected_code=400
)
self.assertTrue('accept_unallocated' in response.data)
# Accept unallocated stock
response = self.post(
finish_url,
{
'accept_unallocated': True,
},
expected_code=201,
)
self.build.refresh_from_db()
# Build should have been marked as complete
self.assertTrue(self.build.is_complete)
class BuildAllocationTest(BuildAPITest):

View File

@ -1,3 +1,7 @@
"""
Utility file to enable simper imports
"""
from .registry import plugin_registry
from .plugin import InvenTreePlugin
from .integration import IntegrationPluginBase

View File

@ -3,6 +3,8 @@ Plugin mixin classes
"""
import logging
import json
import requests
from django.conf.urls import url, include
from django.db.utils import OperationalError, ProgrammingError
@ -321,3 +323,113 @@ class AppMixin:
this plugin is always an app with this plugin
"""
return True
class APICallMixin:
"""
Mixin that enables easier API calls for a plugin
Steps to set up:
1. Add this mixin before (left of) SettingsMixin and PluginBase
2. Add two settings for the required url and token/passowrd (use `SettingsMixin`)
3. Save the references to keys of the settings in `API_URL_SETTING` and `API_TOKEN_SETTING`
4. (Optional) Set `API_TOKEN` to the name required for the token by the external API - Defaults to `Bearer`
5. (Optional) Override the `api_url` property method if the setting needs to be extended
6. (Optional) Override `api_headers` to add extra headers (by default the token and Content-Type are contained)
7. Access the API in you plugin code via `api_call`
Example:
```
from plugin import IntegrationPluginBase
from plugin.mixins import APICallMixin, SettingsMixin
class SampleApiCallerPlugin(APICallMixin, SettingsMixin, IntegrationPluginBase):
'''
A small api call sample
'''
PLUGIN_NAME = "Sample API Caller"
SETTINGS = {
'API_TOKEN': {
'name': 'API Token',
'protected': True,
},
'API_URL': {
'name': 'External URL',
'description': 'Where is your API located?',
'default': 'reqres.in',
},
}
API_URL_SETTING = 'API_URL'
API_TOKEN_SETTING = 'API_TOKEN'
def get_external_url(self):
'''
returns data from the sample endpoint
'''
return self.api_call('api/users/2')
```
"""
API_METHOD = 'https'
API_URL_SETTING = None
API_TOKEN_SETTING = None
API_TOKEN = 'Bearer'
class MixinMeta:
"""meta options for this mixin"""
MIXIN_NAME = 'API calls'
def __init__(self):
super().__init__()
self.add_mixin('api_call', 'has_api_call', __class__)
@property
def has_api_call(self):
"""Is the mixin ready to call external APIs?"""
if not bool(self.API_URL_SETTING):
raise ValueError("API_URL_SETTING must be defined")
if not bool(self.API_TOKEN_SETTING):
raise ValueError("API_TOKEN_SETTING must be defined")
return True
@property
def api_url(self):
return f'{self.API_METHOD}://{self.get_setting(self.API_URL_SETTING)}'
@property
def api_headers(self):
return {
self.API_TOKEN: self.get_setting(self.API_TOKEN_SETTING),
'Content-Type': 'application/json'
}
def api_build_url_args(self, arguments):
groups = []
for key, val in arguments.items():
groups.append(f'{key}={",".join([str(a) for a in val])}')
return f'?{"&".join(groups)}'
def api_call(self, endpoint, method: str = 'GET', url_args=None, data=None, headers=None, simple_response: bool = True):
if url_args:
endpoint += self.api_build_url_args(url_args)
if headers is None:
headers = self.api_headers
# build kwargs for call
kwargs = {
'url': f'{self.api_url}/{endpoint}',
'headers': headers,
}
if data:
kwargs['data'] = json.dumps(data)
# run command
response = requests.request(method, **kwargs)
# return
if simple_response:
return response.json()
return response

View File

@ -2,9 +2,10 @@
Utility class to enable simpler imports
"""
from ..builtin.integration.mixins import AppMixin, SettingsMixin, EventMixin, ScheduleMixin, UrlsMixin, NavigationMixin
from ..builtin.integration.mixins import APICallMixin, AppMixin, SettingsMixin, EventMixin, ScheduleMixin, UrlsMixin, NavigationMixin
__all__ = [
'APICallMixin',
'AppMixin',
'EventMixin',
'NavigationMixin',

View File

@ -0,0 +1,32 @@
"""
Sample plugin for calling an external API
"""
from plugin import IntegrationPluginBase
from plugin.mixins import APICallMixin, SettingsMixin
class SampleApiCallerPlugin(APICallMixin, SettingsMixin, IntegrationPluginBase):
"""
A small api call sample
"""
PLUGIN_NAME = "Sample API Caller"
SETTINGS = {
'API_TOKEN': {
'name': 'API Token',
'protected': True,
},
'API_URL': {
'name': 'External URL',
'description': 'Where is your API located?',
'default': 'reqres.in',
},
}
API_URL_SETTING = 'API_URL'
API_TOKEN_SETTING = 'API_TOKEN'
def get_external_url(self):
"""
returns data from the sample endpoint
"""
return self.api_call('api/users/2')

View File

@ -0,0 +1,21 @@
""" Unit tests for action caller sample"""
from django.test import TestCase
from plugin import plugin_registry
class SampleApiCallerPluginTests(TestCase):
""" Tests for SampleApiCallerPluginTests """
def test_return(self):
"""check if the external api call works"""
# The plugin should be defined
self.assertIn('sample-api-caller', plugin_registry.plugins)
plg = plugin_registry.plugins['sample-api-caller']
self.assertTrue(plg)
# do an api call
result = plg.get_external_url()
self.assertTrue(result)
self.assertIn('data', result,)

View File

@ -8,16 +8,16 @@ from django.contrib.auth import get_user_model
from datetime import datetime
from plugin import IntegrationPluginBase
from plugin.mixins import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin
from plugin.mixins import AppMixin, SettingsMixin, UrlsMixin, NavigationMixin, APICallMixin
from plugin.urls import PLUGIN_BASE
class BaseMixinDefinition:
def test_mixin_name(self):
# mixin name
self.assertEqual(self.mixin.registered_mixins[0]['key'], self.MIXIN_NAME)
self.assertIn(self.MIXIN_NAME, [item['key'] for item in self.mixin.registered_mixins])
# human name
self.assertEqual(self.mixin.registered_mixins[0]['human_name'], self.MIXIN_HUMAN_NAME)
self.assertIn(self.MIXIN_HUMAN_NAME, [item['human_name'] for item in self.mixin.registered_mixins])
class SettingsMixinTest(BaseMixinDefinition, TestCase):
@ -142,6 +142,79 @@ class NavigationMixinTest(BaseMixinDefinition, TestCase):
self.assertEqual(self.nothing_mixin.navigation_name, '')
class APICallMixinTest(BaseMixinDefinition, TestCase):
MIXIN_HUMAN_NAME = 'API calls'
MIXIN_NAME = 'api_call'
MIXIN_ENABLE_CHECK = 'has_api_call'
def setUp(self):
class MixinCls(APICallMixin, SettingsMixin, IntegrationPluginBase):
PLUGIN_NAME = "Sample API Caller"
SETTINGS = {
'API_TOKEN': {
'name': 'API Token',
'protected': True,
},
'API_URL': {
'name': 'External URL',
'description': 'Where is your API located?',
'default': 'reqres.in',
},
}
API_URL_SETTING = 'API_URL'
API_TOKEN_SETTING = 'API_TOKEN'
def get_external_url(self):
'''
returns data from the sample endpoint
'''
return self.api_call('api/users/2')
self.mixin = MixinCls()
class WrongCLS(APICallMixin, IntegrationPluginBase):
pass
self.mixin_wrong = WrongCLS()
class WrongCLS2(APICallMixin, IntegrationPluginBase):
API_URL_SETTING = 'test'
self.mixin_wrong2 = WrongCLS2()
def test_function(self):
# check init
self.assertTrue(self.mixin.has_api_call)
# api_url
self.assertEqual('https://reqres.in', self.mixin.api_url)
# api_headers
headers = self.mixin.api_headers
self.assertEqual(headers, {'Bearer': '', 'Content-Type': 'application/json'})
# 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')
# api_call
result = self.mixin.get_external_url()
self.assertTrue(result)
self.assertIn('data', result,)
# wrongly defined plugins should not load
with self.assertRaises(ValueError):
self.mixin_wrong.has_api_call()
# cover wrong token setting
with self.assertRaises(ValueError):
self.mixin_wrong.has_api_call()
class IntegrationPluginBaseTests(TestCase):
""" Tests for IntegrationPluginBase """

View File

@ -6,6 +6,7 @@
{% settings_value 'REPORT_ENABLE_TEST_REPORT' as test_report_enabled %}
{% settings_value "REPORT_ENABLE" as report_enabled %}
{% settings_value "SERVER_RESTART_REQUIRED" as server_restart_required %}
{% inventree_demo_mode as demo_mode %}
<!DOCTYPE html>
<html lang="en">
@ -90,7 +91,7 @@
{% block alerts %}
<div class='notification-area' id='alerts'>
<!-- Div for displayed alerts -->
{% if server_restart_required %}
{% if server_restart_required and not demo_mode %}
<div id='alert-restart-server' class='alert alert-danger' role='alert'>
<span class='fas fa-server'></span>
<b>{% trans "Server Restart Required" %}</b>

View File

@ -8,7 +8,7 @@
[![Crowdin](https://badges.crowdin.net/inventree/localized.svg)](https://crowdin.com/project/inventree)
![CI](https://github.com/inventree/inventree/actions/workflows/qc_checks.yaml/badge.svg)
InvenTree is an open-source Inventory Management System which provides powerful low-level stock control and part tracking. The core of the InvenTree system is a Python/Django database backend which provides an admin interface (web-based) and a JSON API for interaction with external interfaces and applications.
InvenTree is an open-source Inventory Management System which provides powerful low-level stock control and part tracking. The core of the InvenTree system is a Python/Django database backend which provides an admin interface (web-based) and a REST API for interaction with external interfaces and applications.
InvenTree is designed to be lightweight and easy to use for SME or hobbyist applications, where many existing stock management solutions are bloated and cumbersome to use. Updating stock is a single-action process and does not require a complex system of work orders or stock transactions.