Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2022-05-24 20:32:30 +10:00
commit 4c65b4d5ba
57 changed files with 30612 additions and 29510 deletions

View File

@ -4,7 +4,7 @@ only used for testing the js files! - This file is omited from coverage
"""
import os # pragma: no cover
import pathlib
import pathlib # pragma: no cover
from InvenTree.helpers import InvenTreeTestCase # pragma: no cover

View File

@ -910,6 +910,7 @@ if DEBUG or TESTING:
# Plugin test settings
PLUGIN_TESTING = get_setting('PLUGIN_TESTING', TESTING) # are plugins beeing tested?
PLUGIN_TESTING_SETUP = get_setting('PLUGIN_TESTING_SETUP', False) # load plugins from setup hooks in testing?
PLUGIN_TESTING_EVENTS = False # Flag if events are tested right now
PLUGIN_RETRY = get_setting('PLUGIN_RETRY', 5) # how often should plugin loading be tried?
PLUGIN_FILE_CHECKED = False # Was the plugin file checked?

View File

@ -412,7 +412,7 @@ class CurrencyTests(TestCase):
update_successful = True
break
else:
else: # pragma: no cover
print("Exchange rate update failed - retrying")
time.sleep(1)

View File

@ -76,7 +76,7 @@ class BulkNotificationMethodTests(BaseNotificationIntegrationTest):
def test_BulkNotificationMethod(self):
"""
Ensure the implementation requirements are tested.
NotImplementedError needs to raise if the send_bulk() method is not set.
MixinNotImplementedError needs to raise if the send_bulk() method is not set.
"""
class WrongImplementation(BulkNotificationMethod):
@ -94,7 +94,7 @@ class SingleNotificationMethodTests(BaseNotificationIntegrationTest):
def test_SingleNotificationMethod(self):
"""
Ensure the implementation requirements are tested.
NotImplementedError needs to raise if the send() method is not set.
MixinNotImplementedError needs to raise if the send() method is not set.
"""
class WrongImplementation(SingleNotificationMethod):

View File

@ -156,14 +156,14 @@ class SettingsTest(InvenTreeTestCase):
try:
self.run_settings_check(key, setting)
except Exception as exc:
except Exception as exc: # pragma: no cover
print(f"run_settings_check failed for global setting '{key}'")
raise exc
for key, setting in InvenTreeUserSetting.SETTINGS.items():
try:
self.run_settings_check(key, setting)
except Exception as exc:
except Exception as exc: # pragma: no cover
print(f"run_settings_check failed for user setting '{key}'")
raise exc
@ -501,8 +501,12 @@ class PluginSettingsApiTest(InvenTreeAPITestCase):
"""List installed plugins via API"""
url = reverse('api-plugin-list')
# Simple request
self.get(url, expected_code=200)
# Request with filter
self.get(url, expected_code=200, data={'mixin': 'settings'})
def test_api_list(self):
"""Test list URL"""
url = reverse('api-plugin-setting-list')

View File

@ -4,12 +4,11 @@ from django.conf import settings
from django.core.exceptions import FieldError, ValidationError
from django.http import HttpResponse, JsonResponse
from django.urls import include, re_path
from django.utils.translation import gettext_lazy as _
from django_filters.rest_framework import DjangoFilterBackend
from PIL import Image
from rest_framework import filters, generics
from rest_framework.response import Response
from rest_framework.exceptions import NotFound
import common.models
import InvenTree.helpers
@ -62,10 +61,14 @@ class LabelPrintMixin:
"""
if not settings.PLUGINS_ENABLED:
return None
return None # pragma: no cover
plugin_key = request.query_params.get('plugin', None)
# No plugin provided, and that's OK
if plugin_key is None:
return None
plugin = registry.get_plugin(plugin_key)
if plugin:
@ -74,9 +77,10 @@ class LabelPrintMixin:
if config and config.active:
# Only return the plugin if it is enabled!
return plugin
# No matches found
return None
else:
raise ValidationError(f"Plugin '{plugin_key}' is not enabled")
else:
raise NotFound(f"Plugin '{plugin_key}' not found")
def print(self, request, items_to_print):
"""
@ -85,13 +89,11 @@ class LabelPrintMixin:
# Check the request to determine if the user has selected a label printing plugin
plugin = self.get_plugin(request)
if len(items_to_print) == 0:
# No valid items provided, return an error message
data = {
'error': _('No valid objects provided to template'),
}
return Response(data, status=400)
raise ValidationError('No valid objects provided to label template')
outputs = []
@ -281,7 +283,7 @@ class StockItemLabelList(LabelListView, StockItemLabelMixin):
# Filter string defined for the StockItemLabel object
try:
filters = InvenTree.helpers.validateFilterString(label.filters)
except ValidationError:
except ValidationError: # pragma: no cover
continue
for item in items:
@ -300,7 +302,7 @@ class StockItemLabelList(LabelListView, StockItemLabelMixin):
if matches:
valid_label_ids.add(label.pk)
else:
continue
continue # pragma: no cover
# Reduce queryset to only valid matches
queryset = queryset.filter(pk__in=[pk for pk in valid_label_ids])
@ -412,7 +414,7 @@ class StockLocationLabelList(LabelListView, StockLocationLabelMixin):
# Filter string defined for the StockLocationLabel object
try:
filters = InvenTree.helpers.validateFilterString(label.filters)
except:
except: # pragma: no cover
# Skip if there was an error validating the filters...
continue
@ -432,7 +434,7 @@ class StockLocationLabelList(LabelListView, StockLocationLabelMixin):
if matches:
valid_label_ids.add(label.pk)
else:
continue
continue # pragma: no cover
# Reduce queryset to only valid matches
queryset = queryset.filter(pk__in=[pk for pk in valid_label_ids])
@ -519,7 +521,7 @@ class PartLabelList(LabelListView, PartLabelMixin):
try:
filters = InvenTree.helpers.validateFilterString(label.filters)
except ValidationError:
except ValidationError: # pragma: no cover
continue
for part in parts:

View File

@ -46,7 +46,7 @@ class LabelConfig(AppConfig):
try:
from .models import StockLocationLabel
assert bool(StockLocationLabel is not None)
except AppRegistryNotReady:
except AppRegistryNotReady: # pragma: no cover
# Database might not yet be ready
warnings.warn('Database was not ready for creating labels')
return

View File

@ -8,7 +8,6 @@ import os
import sys
from django.conf import settings
from django.core.exceptions import FieldError, ValidationError
from django.core.validators import FileExtensionValidator, MinValueValidator
from django.db import models
from django.template import Context, Template
@ -171,7 +170,7 @@ class LabelTemplate(models.Model):
Note: Override this in any subclass
"""
return {}
return {} # pragma: no cover
def generate_filename(self, request, **kwargs):
"""
@ -242,7 +241,7 @@ class StockItemLabel(LabelTemplate):
@staticmethod
def get_api_url():
return reverse('api-stockitem-label-list')
return reverse('api-stockitem-label-list') # pragma: no cover
SUBDIR = "stockitem"
@ -255,22 +254,6 @@ class StockItemLabel(LabelTemplate):
]
)
def matches_stock_item(self, item):
"""
Test if this label template matches a given StockItem object
"""
try:
filters = validateFilterString(self.filters)
items = stock.models.StockItem.objects.filter(**filters)
except (ValidationError, FieldError):
# If an error exists with the "filters" field, return False
return False
items = items.filter(pk=item.pk)
return items.exists()
def get_context_data(self, request):
"""
Generate context data for each provided StockItem
@ -302,7 +285,7 @@ class StockLocationLabel(LabelTemplate):
@staticmethod
def get_api_url():
return reverse('api-stocklocation-label-list')
return reverse('api-stocklocation-label-list') # pragma: no cover
SUBDIR = "stocklocation"
@ -314,21 +297,6 @@ class StockLocationLabel(LabelTemplate):
validate_stock_location_filters]
)
def matches_stock_location(self, location):
"""
Test if this label template matches a given StockLocation object
"""
try:
filters = validateFilterString(self.filters)
locs = stock.models.StockLocation.objects.filter(**filters)
except (ValidationError, FieldError):
return False
locs = locs.filter(pk=location.pk)
return locs.exists()
def get_context_data(self, request):
"""
Generate context data for each provided StockLocation
@ -349,7 +317,7 @@ class PartLabel(LabelTemplate):
@staticmethod
def get_api_url():
return reverse('api-part-label-list')
return reverse('api-part-label-list') # pragma: no cover
SUBDIR = 'part'
@ -362,21 +330,6 @@ class PartLabel(LabelTemplate):
]
)
def matches_part(self, part):
"""
Test if this label template matches a given Part object
"""
try:
filters = validateFilterString(self.filters)
parts = part.models.Part.objects.filter(**filters)
except (ValidationError, FieldError):
return False
parts = parts.filter(pk=part.pk)
return parts.exists()
def get_context_data(self, request):
"""
Generate context data for each provided Part object

View File

@ -63,39 +63,3 @@ class TestReportTests(InvenTreeAPITestCase):
'items': [10, 11, 12],
}
)
class TestLabels(InvenTreeAPITestCase):
"""
Tests for the label APIs
"""
fixtures = [
'category',
'part',
'location',
'stock',
]
roles = [
'stock.view',
'stock_location.view',
]
def do_list(self, filters={}):
response = self.client.get(self.list_url, filters, format='json')
self.assertEqual(response.status_code, 200)
return response.data
def test_lists(self):
self.list_url = reverse('api-stockitem-label-list')
self.do_list()
self.list_url = reverse('api-stocklocation-label-list')
self.do_list()
self.list_url = reverse('api-part-label-list')
self.do_list()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1205,8 +1205,9 @@ class SalesOrderShipment(models.Model):
def is_complete(self):
return self.shipment_date is not None
def check_can_complete(self):
def check_can_complete(self, raise_error=True):
try:
if self.shipment_date:
# Shipment has already been sent!
raise ValidationError(_("Shipment has already been sent"))
@ -1214,6 +1215,14 @@ class SalesOrderShipment(models.Model):
if self.allocations.count() == 0:
raise ValidationError(_("Shipment has no allocated stock items"))
except ValidationError as e:
if raise_error:
raise e
else:
return False
return True
@transaction.atomic
def complete_shipment(self, user, **kwargs):
"""
@ -1235,7 +1244,7 @@ class SalesOrderShipment(models.Model):
allocation.complete_allocation(user)
# Update the "shipment" date
self.shipment_date = datetime.now()
self.shipment_date = kwargs.get('shipment_date', datetime.now())
self.shipped_by = user
# Was a tracking number provided?

View File

@ -2,6 +2,7 @@
JSON serializers for the Order API
"""
from datetime import datetime
from decimal import Decimal
from django.core.exceptions import ValidationError as DjangoValidationError
@ -899,6 +900,7 @@ class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer):
fields = [
'tracking_number',
'shipment_date',
]
def validate(self, data):
@ -910,7 +912,7 @@ class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer):
if not shipment:
raise ValidationError(_("No shipment details provided"))
shipment.check_can_complete()
shipment.check_can_complete(raise_error=True)
return data
@ -927,9 +929,16 @@ class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer):
user = request.user
# Extract provided tracking number (optional)
tracking_number = data.get('tracking_number', None)
tracking_number = data.get('tracking_number', shipment.tracking_number)
shipment.complete_shipment(user, tracking_number=tracking_number)
# Extract shipping date (defaults to today's date)
shipment_date = data.get('shipment_date', datetime.now())
shipment.complete_shipment(
user,
tracking_number=tracking_number,
shipment_date=shipment_date,
)
class SalesOrderShipmentAllocationItemSerializer(serializers.Serializer):

View File

@ -5,6 +5,7 @@ Tests for the Order API
import io
from datetime import datetime, timedelta
from django.core.exceptions import ValidationError
from django.urls import reverse
from rest_framework import status
@ -1275,3 +1276,96 @@ class SalesOrderAllocateTest(OrderTest):
for line in self.order.lines.all():
self.assertEqual(line.allocations.count(), 1)
def test_shipment_complete(self):
"""Test that we can complete a shipment via the API"""
url = reverse('api-so-shipment-ship', kwargs={'pk': self.shipment.pk})
self.assertFalse(self.shipment.is_complete())
self.assertFalse(self.shipment.check_can_complete(raise_error=False))
with self.assertRaises(ValidationError):
self.shipment.check_can_complete()
# Attempting to complete this shipment via the API should fail
response = self.post(
url, {},
expected_code=400
)
self.assertIn('Shipment has no allocated stock items', str(response.data))
# Allocate stock against this shipment
line = self.order.lines.first()
part = line.part
models.SalesOrderAllocation.objects.create(
shipment=self.shipment,
line=line,
item=part.stock_items.last(),
quantity=5
)
# Shipment should now be able to be completed
self.assertTrue(self.shipment.check_can_complete())
# Attempt with an invalid date
response = self.post(
url,
{
'shipment_date': 'asfasd',
},
expected_code=400,
)
self.assertIn('Date has wrong format', str(response.data))
response = self.post(
url,
{
'tracking_number': 'TRK12345',
'shipment_date': '2020-12-05',
},
expected_code=201,
)
self.shipment.refresh_from_db()
self.assertTrue(self.shipment.is_complete())
self.assertEqual(self.shipment.tracking_number, 'TRK12345')
def test_sales_order_shipment_list(self):
url = reverse('api-so-shipment-list')
# Create some new shipments via the API
for order in models.SalesOrder.objects.all():
for idx in range(3):
self.post(
url,
{
'order': order.pk,
'reference': f"SH{idx + 1}",
'tracking_number': f"TRK_{order.pk}_{idx}"
},
expected_code=201
)
# Filter API by order
response = self.get(
url,
{
'order': order.pk,
},
expected_code=200,
)
# 3 shipments returned for each SalesOrder instance
self.assertGreaterEqual(len(response.data), 3)
# List *all* shipments
response = self.get(url, expected_code=200)
self.assertEqual(len(response.data), 1 + 3 * models.SalesOrder.objects.count())

View File

@ -58,7 +58,7 @@ class TemplateTagTest(InvenTreeTestCase):
def test_hash(self):
result_hash = inventree_extras.inventree_commit_hash()
if settings.DOCKER:
if settings.DOCKER: # pragma: no cover
# Testing inside docker environment *may* return an empty git commit hash
# In such a case, skip this check
pass
@ -67,7 +67,7 @@ class TemplateTagTest(InvenTreeTestCase):
def test_date(self):
d = inventree_extras.inventree_commit_date()
if settings.DOCKER:
if settings.DOCKER: # pragma: no cover
# Testing inside docker environment *may* return an empty git commit hash
# In such a case, skip this check
pass

View File

@ -20,7 +20,7 @@ class PluginAppConfig(AppConfig):
def ready(self):
if settings.PLUGINS_ENABLED:
if not canAppAccessDatabase(allow_test=True):
logger.info("Skipping plugin loading sequence")
logger.info("Skipping plugin loading sequence") # pragma: no cover
else:
logger.info('Loading InvenTree plugins')
@ -48,4 +48,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")
logger.info("Plugins not enabled - skipping loading sequence") # pragma: no cover

View File

@ -26,9 +26,10 @@ def trigger_event(event, *args, **kwargs):
if not settings.PLUGINS_ENABLED:
# Do nothing if plugins are not enabled
return
return # pragma: no cover
if not canAppAccessDatabase():
# Make sure the database can be accessed and is not beeing tested rn
if not canAppAccessDatabase() and not settings.PLUGIN_TESTING_EVENTS:
logger.debug(f"Ignoring triggered event '{event}' - database not ready")
return
@ -91,7 +92,7 @@ def process_event(plugin_slug, event, *args, **kwargs):
plugin = registry.plugins.get(plugin_slug, None)
if plugin is None:
if plugin is None: # pragma: no cover
logger.error(f"Could not find matching plugin for '{plugin_slug}'")
return
@ -106,7 +107,7 @@ def allow_table_event(table_name):
if isImportingData():
# Prevent table events during the data import process
return False
return False # pragma: no cover
table_name = table_name.lower().strip()

View File

@ -57,10 +57,10 @@ class SettingsMixin:
except (OperationalError, ProgrammingError): # pragma: no cover
plugin = None
if not plugin:
if not plugin: # pragma: no cover
# Cannot find associated plugin model, return
logger.error(f"Plugin configuration not found for plugin '{self.slug}'")
return # pragma: no cover
return
PluginSetting.set_setting(key, value, user, plugin=plugin)
@ -541,7 +541,7 @@ class PanelMixin:
def get_custom_panels(self, view, request):
""" This method *must* be implemented by the plugin class """
raise NotImplementedError(f"{__class__} is missing the 'get_custom_panels' method")
raise MixinNotImplementedError(f"{__class__} is missing the 'get_custom_panels' method")
def get_panel_context(self, view, request, context):
"""
@ -559,7 +559,7 @@ class PanelMixin:
try:
context['object'] = view.get_object()
except AttributeError:
except AttributeError: # pragma: no cover
pass
return context

View File

@ -8,6 +8,7 @@ from error_report.models import Error
from InvenTree.helpers import InvenTreeTestCase
from plugin import InvenTreePlugin
from plugin.base.integration.mixins import PanelMixin
from plugin.helpers import MixinNotImplementedError
from plugin.mixins import (APICallMixin, AppMixin, NavigationMixin,
SettingsMixin, UrlsMixin)
@ -324,7 +325,7 @@ class PanelMixinTests(InvenTreeTestCase):
urls = [
reverse('part-detail', kwargs={'pk': 1}),
reverse('stock-item-detail', kwargs={'pk': 2}),
reverse('stock-location-detail', kwargs={'pk': 1}),
reverse('stock-location-detail', kwargs={'pk': 2}),
]
plugin.set_setting('ENABLE_HELLO_WORLD', False)
@ -379,3 +380,13 @@ class PanelMixinTests(InvenTreeTestCase):
# Assert that each request threw an error
self.assertEqual(Error.objects.count(), n_errors + len(urls))
def test_mixin(self):
"""Test that ImplementationError is raised"""
with self.assertRaises(MixinNotImplementedError):
class Wrong(PanelMixin, InvenTreePlugin):
pass
plugin = Wrong()
plugin.get_custom_panels('abc', 'abc')

View File

@ -26,13 +26,13 @@ def print_label(plugin_slug, label_image, label_instance=None, user=None):
plugin = registry.plugins.get(plugin_slug, None)
if plugin is None:
if plugin is None: # pragma: no cover
logger.error(f"Could not find matching plugin for '{plugin_slug}'")
return
try:
plugin.print_label(label_image, width=label_instance.width, height=label_instance.height)
except Exception as e:
except Exception as e: # pragma: no cover
# Plugin threw an error - notify the user who attempted to print
ctx = {

View File

@ -0,0 +1,209 @@
"""Unit tests for the label printing mixin"""
from django.apps import apps
from django.urls import reverse
from common.models import InvenTreeSetting
from InvenTree.api_tester import InvenTreeAPITestCase
from label.models import PartLabel, StockItemLabel, StockLocationLabel
from part.models import Part
from plugin.base.label.mixins import LabelPrintingMixin
from plugin.helpers import MixinNotImplementedError
from plugin.plugin import InvenTreePlugin
from plugin.registry import registry
from stock.models import StockItem, StockLocation
class LabelMixinTests(InvenTreeAPITestCase):
"""Test that the Label mixin operates correctly"""
fixtures = [
'category',
'part',
'location',
'stock',
]
roles = 'all'
def do_activate_plugin(self):
"""Activate the 'samplelabel' plugin"""
config = registry.get_plugin('samplelabel').plugin_config()
config.active = True
config.save()
def do_url(self, parts, plugin_ref, label, url_name: str = 'api-part-label-print', url_single: str = 'part', invalid: bool = False):
"""Generate an URL to print a label"""
# Construct URL
kwargs = {}
if label:
kwargs["pk"] = label.pk
url = reverse(url_name, kwargs=kwargs)
# Append part filters
if not parts:
pass
elif len(parts) == 1:
url += f'?{url_single}={parts[0].pk}'
elif len(parts) > 1:
url += '?' + '&'.join([f'{url_single}s={item.pk}' for item in parts])
# Append an invalid item
if invalid:
url += f'&{url_single}{"s" if len(parts) > 1 else ""}=abc'
# Append plugin reference
if plugin_ref:
url += f'&plugin={plugin_ref}'
return url
def test_wrong_implementation(self):
"""Test that a wrong implementation raises an error"""
class WrongPlugin(LabelPrintingMixin, InvenTreePlugin):
pass
with self.assertRaises(MixinNotImplementedError):
plugin = WrongPlugin()
plugin.print_label('test')
def test_installed(self):
"""Test that the sample printing plugin is installed"""
# Get all label plugins
plugins = registry.with_mixin('labels')
self.assertEqual(len(plugins), 1)
# But, it is not 'active'
plugins = registry.with_mixin('labels', active=True)
self.assertEqual(len(plugins), 0)
def test_api(self):
"""Test that we can filter the API endpoint by mixin"""
url = reverse('api-plugin-list')
# Try POST (disallowed)
response = self.client.post(url, {})
self.assertEqual(response.status_code, 405)
response = self.client.get(
url,
{
'mixin': 'labels',
'active': True,
}
)
# No results matching this query!
self.assertEqual(len(response.data), 0)
# What about inactive?
response = self.client.get(
url,
{
'mixin': 'labels',
'active': False,
}
)
self.assertEqual(len(response.data), 0)
self.do_activate_plugin()
# Should be available via the API now
response = self.client.get(
url,
{
'mixin': 'labels',
'active': True,
}
)
self.assertEqual(len(response.data), 1)
data = response.data[0]
self.assertEqual(data['key'], 'samplelabel')
def test_printing_process(self):
"""Test that a label can be printed"""
# Ensure the labels were created
apps.get_app_config('label').create_labels()
# Lookup references
part = Part.objects.first()
plugin_ref = 'samplelabel'
label = PartLabel.objects.first()
url = self.do_url([part], plugin_ref, label)
# Non-exsisting plugin
response = self.get(f'{url}123', expected_code=404)
self.assertIn(f'Plugin \'{plugin_ref}123\' not found', str(response.content, 'utf8'))
# Inactive plugin
response = self.get(url, expected_code=400)
self.assertIn(f'Plugin \'{plugin_ref}\' is not enabled', str(response.content, 'utf8'))
# Active plugin
self.do_activate_plugin()
# Print one part
self.get(url, expected_code=200)
# Print multiple parts
self.get(self.do_url(Part.objects.all()[:2], plugin_ref, label), expected_code=200)
# Print multiple parts without a plugin
self.get(self.do_url(Part.objects.all()[:2], None, label), expected_code=200)
# Print multiple parts without a plugin in debug mode
InvenTreeSetting.set_setting('REPORT_DEBUG_MODE', True, None)
response = self.get(self.do_url(Part.objects.all()[:2], None, label), expected_code=200)
self.assertIn('@page', str(response.content))
# Print no part
self.get(self.do_url(None, plugin_ref, label), expected_code=400)
def test_printing_endpoints(self):
"""Cover the endpoints not covered by `test_printing_process`"""
plugin_ref = 'samplelabel'
# Activate the label components
apps.get_app_config('label').create_labels()
self.do_activate_plugin()
def run_print_test(label, qs, url_name, url_single):
"""Run tests on single and multiple page printing
Args:
label (_type_): class of the label
qs (_type_): class of the base queryset
url_name (_type_): url for endpoints
url_single (_type_): item lookup reference
"""
label = label.objects.first()
qs = qs.objects.all()
# List endpoint
self.get(self.do_url(None, None, None, f'{url_name}-list', url_single), expected_code=200)
# List endpoint with filter
self.get(self.do_url(qs[:2], None, None, f'{url_name}-list', url_single, invalid=True), expected_code=200)
# Single page printing
self.get(self.do_url(qs[:1], plugin_ref, label, f'{url_name}-print', url_single), expected_code=200)
# Multi page printing
self.get(self.do_url(qs[:2], plugin_ref, label, f'{url_name}-print', url_single), expected_code=200)
# Test StockItemLabels
run_print_test(StockItemLabel, StockItem, 'api-stockitem-label', 'item')
# Test StockLocationLabels
run_print_test(StockLocationLabel, StockLocation, 'api-stocklocation-label', 'location')
# Test PartLabels
run_print_test(PartLabel, Part, 'api-part-label', 'part')

View File

@ -2,7 +2,7 @@
import logging
from plugin.helpers import MixinImplementationError
from plugin.helpers import MixinNotImplementedError
logger = logging.getLogger('inventree')
@ -58,7 +58,7 @@ class LocateMixin:
if item.in_stock and item.location is not None:
self.locate_stock_location(item.location.pk)
except StockItem.DoesNotExist:
except StockItem.DoesNotExist: # pragma: no cover
logger.warning("LocateMixin: StockItem pk={item_pk} not found")
pass
@ -71,4 +71,4 @@ class LocateMixin:
Note: The default implementation here does nothing!
"""
raise MixinImplementationError
raise MixinNotImplementedError

View File

@ -5,7 +5,8 @@ Unit tests for the 'locate' plugin mixin class
from django.urls import reverse
from InvenTree.api_tester import InvenTreeAPITestCase
from plugin.registry import registry
from plugin import InvenTreePlugin, MixinNotImplementedError, registry
from plugin.base.locate.mixins import LocateMixin
from stock.models import StockItem, StockLocation
@ -145,3 +146,17 @@ class LocatePluginTests(InvenTreeAPITestCase):
# Item metadata should have been altered!
self.assertTrue(location.metadata['located'])
def test_mixin_locate(self):
"""Test the sample mixin redirection"""
class SamplePlugin(LocateMixin, InvenTreePlugin):
pass
plugin = SamplePlugin()
# Test that the request is patched through to location
with self.assertRaises(MixinNotImplementedError):
plugin.locate_stock_item(1)
# Test that it runs through
plugin.locate_stock_item(999)

View File

@ -2,6 +2,10 @@
Sample plugin which responds to events
"""
import warnings
from django.conf import settings
from plugin import InvenTreePlugin
from plugin.mixins import EventMixin
@ -21,3 +25,7 @@ class EventPluginSample(EventMixin, InvenTreePlugin):
print(f"Processing triggered event: '{event}'")
print("args:", str(args))
print("kwargs:", str(kwargs))
# Issue warning that we can test for
if settings.PLUGIN_TESTING:
warnings.warn(f'Event `{event}` triggered')

View File

@ -0,0 +1,40 @@
"""Unit tests for event_sample sample plugins"""
from django.conf import settings
from django.test import TestCase
from plugin import InvenTreePlugin, registry
from plugin.base.event.events import trigger_event
from plugin.helpers import MixinNotImplementedError
from plugin.mixins import EventMixin
class EventPluginSampleTests(TestCase):
"""Tests for EventPluginSample"""
def test_run_event(self):
"""Check if the event is issued"""
# Activate plugin
config = registry.get_plugin('sampleevent').plugin_config()
config.active = True
config.save()
# Enable event testing
settings.PLUGIN_TESTING_EVENTS = True
# Check that an event is issued
with self.assertWarns(Warning) as cm:
trigger_event('test.event')
self.assertEqual(cm.warning.args[0], 'Event `test.event` triggered')
# Disable again
settings.PLUGIN_TESTING_EVENTS = False
def test_mixin(self):
"""Test that MixinNotImplementedError is raised"""
with self.assertRaises(MixinNotImplementedError):
class Wrong(EventMixin, InvenTreePlugin):
pass
plugin = Wrong()
plugin.process_event('abc')

View File

@ -120,7 +120,7 @@ class CustomPanelSample(PanelMixin, SettingsMixin, InvenTreePlugin):
'icon': 'fa-user',
'content_template': 'panel_demo/childless.html', # Note that the panel content is rendered using a template file!
})
except:
except: # pragma: no cover
pass
return panels

View File

@ -0,0 +1,18 @@
from plugin import InvenTreePlugin
from plugin.mixins import LabelPrintingMixin
class SampleLabelPrinter(LabelPrintingMixin, InvenTreePlugin):
"""
Sample plugin which provides a 'fake' label printer endpoint
"""
NAME = "Label Printer"
SLUG = "samplelabel"
TITLE = "Sample Label Printer"
DESCRIPTION = "A sample plugin which provides a (fake) label printer interface"
VERSION = "0.1"
def print_label(self, label, **kwargs):
print("OK PRINTING")

View File

@ -37,7 +37,7 @@ class SampleLocatePlugin(LocateMixin, InvenTreePlugin):
# Tag metadata
item.set_metadata('located', True)
except (ValueError, StockItem.DoesNotExist):
except (ValueError, StockItem.DoesNotExist): # pragma: no cover
logger.error(f"StockItem ID {item_pk} does not exist!")
def locate_stock_location(self, location_pk):
@ -53,5 +53,5 @@ class SampleLocatePlugin(LocateMixin, InvenTreePlugin):
# Tag metadata
location.set_metadata('located', True)
except (ValueError, StockLocation.DoesNotExist):
except (ValueError, StockLocation.DoesNotExist): # pragma: no cover
logger.error(f"Location ID {location_pk} does not exist!")

View File

@ -0,0 +1,60 @@
"""Unit tests for locate_sample sample plugins"""
from django.urls import reverse
from InvenTree.api_tester import InvenTreeAPITestCase
from plugin import InvenTreePlugin, registry
from plugin.helpers import MixinNotImplementedError
from plugin.mixins import LocateMixin
class SampleLocatePlugintests(InvenTreeAPITestCase):
"""Tests for SampleLocatePlugin"""
fixtures = [
'location',
'category',
'part',
'stock'
]
def test_run_locator(self):
"""Check if the event is issued"""
# Activate plugin
config = registry.get_plugin('samplelocate').plugin_config()
config.active = True
config.save()
# Test APIs
url = reverse('api-locate-plugin')
# No plugin
self.post(url, {}, expected_code=400)
# Wrong plugin
self.post(url, {'plugin': 'sampleevent'}, expected_code=400)
# Right plugin - no search item
self.post(url, {'plugin': 'samplelocate'}, expected_code=400)
# Right plugin - wrong reference
self.post(url, {'plugin': 'samplelocate', 'item': 999}, expected_code=404)
# Right plugin - right reference
self.post(url, {'plugin': 'samplelocate', 'item': 1}, expected_code=200)
# Right plugin - wrong reference
self.post(url, {'plugin': 'samplelocate', 'location': 999}, expected_code=404)
# Right plugin - right reference
self.post(url, {'plugin': 'samplelocate', 'location': 1}, expected_code=200)
def test_mixin(self):
"""Test that MixinNotImplementedError is raised"""
with self.assertRaises(MixinNotImplementedError):
class Wrong(LocateMixin, InvenTreePlugin):
pass
plugin = Wrong()
plugin.locate_stock_location(1)

View File

@ -156,158 +156,7 @@
{% endif %}
</td>
</tr>
{% if item.serialized %}
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Serial Number" %}</td>
<td>
{{ item.serial }}
<div class='btn-group float-right' role='group'>
{% if previous %}
<a class="btn btn-small btn-outline-secondary" aria-label="{% trans 'previous page' %}" href="{% url request.resolver_match.url_name previous.id %}" title='{% trans "Navigate to previous serial number" %}'>
<span class='fas fa-angle-left'></span>
<small>{{ previous.serial }}</small>
</a>
{% endif %}
<a class='btn btn-small btn-outline-secondary text-sm' href='#' id='serial-number-search' title='{% trans "Search for serial number" %}'>
<span class='fas fa-search'></span>
</a>
{% if next %}
<a class="btn btn-small btn-outline-secondary text-sm" aria-label="{% trans 'next page' %}" href="{% url request.resolver_match.url_name next.id %}" title='{% trans "Navigate to next serial number" %}'>
<small>{{ next.serial }}</small>
<span class='fas fa-angle-right'></span>
</a>
{% endif %}
</div>
</td>
</tr>
{% else %}
<tr>
<td></td>
<td>{% trans "Quantity" %}</td>
<td>{% decimal item.quantity %} {% if item.part.units %}{{ item.part.units }}{% endif %}</td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-info'></span></td>
<td>{% trans "Status" %}</td>
<td>{% stock_status_label item.status %}</td>
</tr>
{% if item.expiry_date %}
<tr>
<td><span class='fas fa-calendar-alt{% if item.is_expired %} icon-red{% endif %}'></span></td>
<td>{% trans "Expiry Date" %}</td>
<td>
{% render_date item.expiry_date %}
{% if item.is_expired %}
<span title='{% blocktrans %}This StockItem expired on {{ item.expiry_date }}{% endblocktrans %}' class='badge rounded-pill bg-danger badge-right'>{% trans "Expired" %}</span>
{% elif item.is_stale %}
<span title='{% blocktrans %}This StockItem expires on {{ item.expiry_date }}{% endblocktrans %}' class='badge rounded-pill bg-warning badge-right'>{% trans "Stale" %}</span>
{% endif %}
</td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Last Updated" %}</td>
<td>{{ item.updated }}</td>
</tr>
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Last Stocktake" %}</td>
{% if item.stocktake_date %}
<td>{% render_date item.stocktake_date %} <span class='badge badge-right rounded-pill bg-dark'>{{ item.stocktake_user }}</span></td>
{% else %}
<td><em>{% trans "No stocktake performed" %}</em></td>
{% endif %}
</tr>
</table>
<div class='info-messages'>
{% if item.is_building %}
<div class='alert alert-block alert-info'>
{% trans "This stock item is in production and cannot be edited." %}<br>
{% trans "Edit the stock item from the build view." %}<br>
{% if item.build %}
<a href="{% url 'build-detail' item.build.id %}">
<strong>{{ item.build }}</strong>
</a>
{% endif %}
</div>
{% endif %}
{% if item.hasRequiredTests and not item.passedAllRequiredTests %}
<div class='alert alert-block alert-danger'>
{% trans "This stock item has not passed all required tests" %}
</div>
{% endif %}
{% for allocation in item.sales_order_allocations.all %}
<div class='alert alert-block alert-info'>
{% object_link 'so-detail' allocation.line.order.id allocation.line.order as link %}
{% decimal allocation.quantity as qty %}
{% trans "This stock item is allocated to Sales Order" %} {{ link }} {% if qty < item.quantity %}({% trans "Quantity" %}: {{ qty }}){% endif %}
</div>
{% endfor %}
{% for allocation in item.allocations.all %}
<div class='alert alert-block alert-info'>
{% object_link 'build-detail' allocation.build.id allocation.build as link %}
{% decimal allocation.quantity as qty %}
{% trans "This stock item is allocated to Build Order" %} {{ link }} {% if qty < item.quantity %}({% trans "Quantity" %}: {{ qty }}){% endif %}
</div>
{% endfor %}
{% if item.serialized %}
<div class='alert alert-block alert-warning'>
{% trans "This stock item is serialized - it has a unique serial number and the quantity cannot be adjusted." %}
</div>
{% endif %}
</div>
{% endblock details %}
{% block details_right %}
<table class="table table-striped table-condensed">
<col width='25'>
{% if item.customer %}
<tr>
<td><span class='fas fa-user-tie'></span></td>
<td>{% trans "Customer" %}</td>
<td><a href="{% url 'company-detail' item.customer.id %}?display=assigned-stock">{{ item.customer.name }}</a></td>
</tr>
{% endif %}
{% if item.belongs_to %}
<tr>
<td><span class='fas fa-box'></span></td>
<td>
{% trans "Installed In" %}
</td>
<td>
<a href="{% url 'stock-item-detail' item.belongs_to.id %}">{{ item.belongs_to }}</a>
</td>
</tr>
{% elif item.sales_order %}
<tr>
<td><span class='fas fa-user-tie'></span></td>
<td>{% trans "Sales Order" %}</td>
<td><a href="{% url 'so-detail' item.sales_order.id %}">{{ item.sales_order.reference }}</a> - <a href="{% url 'company-detail' item.sales_order.customer.id %}">{{ item.sales_order.customer.name }}</a></td>
</tr>
{% else %}
<tr>
<td><span class='fas fa-map-marker-alt'></span></td>
<td>{% trans "Location" %}</td>
{% if item.location %}
<td><a href="{% url 'stock-location-detail' item.location.id %}">{{ item.location.name }}</a></td>
{% else %}
<td><em>{% trans "No location set" %}</em></td>
{% endif %}
</tr>
{% endif %}
{% if item.uid %}
<tr>
<td><span class='fas fa-barcode'></span></td>
@ -322,13 +171,6 @@
<td>{{ item.batch }}</td>
</tr>
{% endif %}
{% if item.packaging %}
<tr>
<td><span class='fas fa-cube'></span></td>
<td>{% trans "Packaging" %}</td>
<td>{{ item.packaging }}</td>
</tr>
{% endif %}
{% if item.build %}
<tr>
<td><span class='fas fa-tools'></span></td>
@ -397,18 +239,11 @@
<td><a href="{% url 'supplier-part-detail' item.supplier_part.id %}">{{ item.supplier_part.SKU }}</a></td>
</tr>
{% endif %}
{% if item.hasRequiredTests %}
{% if item.packaging %}
<tr>
<td><span class='fas fa-vial'></span></td>
<td>{% trans "Tests" %}</td>
<td>
{{ item.requiredTestStatus.passed }} / {{ item.requiredTestStatus.total }}
{% if item.passedAllRequiredTests %}
<span class='fas fa-check-circle float-right icon-green'></span>
{% else %}
<span class='fas fa-times-circle float-right icon-red'></span>
{% endif %}
</td>
<td><span class='fas fa-cube'></span></td>
<td>{% trans "Packaging" %}</td>
<td>{{ item.packaging }}</td>
</tr>
{% endif %}
{% if ownership_enabled and item_owner %}
@ -425,6 +260,199 @@
</td>
</tr>
{% endif %}
</table>
<div class='info-messages'>
{% if item.is_building %}
<div class='alert alert-block alert-info'>
{% trans "This stock item is in production and cannot be edited." %}<br>
{% trans "Edit the stock item from the build view." %}<br>
{% if item.build %}
<a href="{% url 'build-detail' item.build.id %}">
<strong>{{ item.build }}</strong>
</a>
{% endif %}
</div>
{% endif %}
{% if item.hasRequiredTests and not item.passedAllRequiredTests %}
<div class='alert alert-block alert-danger'>
{% trans "This stock item has not passed all required tests" %}
</div>
{% endif %}
{% for allocation in item.sales_order_allocations.all %}
<div class='alert alert-block alert-info'>
{% object_link 'so-detail' allocation.line.order.id allocation.line.order as link %}
{% decimal allocation.quantity as qty %}
{% trans "This stock item is allocated to Sales Order" %} {{ link }} {% if qty < item.quantity %}({% trans "Quantity" %}: {{ qty }}){% endif %}
</div>
{% endfor %}
{% for allocation in item.allocations.all %}
<div class='alert alert-block alert-info'>
{% object_link 'build-detail' allocation.build.id allocation.build as link %}
{% decimal allocation.quantity as qty %}
{% trans "This stock item is allocated to Build Order" %} {{ link }} {% if qty < item.quantity %}({% trans "Quantity" %}: {{ qty }}){% endif %}
</div>
{% endfor %}
{% if item.serialized %}
<div class='alert alert-block alert-warning'>
{% trans "This stock item is serialized - it has a unique serial number and the quantity cannot be adjusted." %}
</div>
{% endif %}
</div>
{% endblock details %}
{% block details_right %}
<table class="table table-striped table-condensed">
<col width='25'>
<tr>
{% if item.serialized %}
<td>
<h5><span class='fas fa-hashtag'></span></h5>
</td>
<td>
<h5>{% trans "Serial Number" %}</h5>
</td>
<td><h5>
{{ item.serial }}
<div class='btn-group float-right' role='group'>
{% if previous %}
<a class="btn btn-small btn-outline-secondary" aria-label="{% trans 'previous page' %}" href="{% url request.resolver_match.url_name previous.id %}" title='{% trans "Navigate to previous serial number" %}'>
<span class='fas fa-angle-left'></span>
<small>{{ previous.serial }}</small>
</a>
{% endif %}
<a class='btn btn-small btn-outline-secondary text-sm' href='#' id='serial-number-search' title='{% trans "Search for serial number" %}'>
<span class='fas fa-search'></span>
</a>
{% if next %}
<a class="btn btn-small btn-outline-secondary text-sm" aria-label="{% trans 'next page' %}" href="{% url request.resolver_match.url_name next.id %}" title='{% trans "Navigate to next serial number" %}'>
<small>{{ next.serial }}</small>
<span class='fas fa-angle-right'></span>
</a>
{% endif %}
</div>
</h5>
</td>
{% else %}
<td>
<h5><div class='fas fa-boxes'></div></h5>
</td>
<td>
<h5>{% trans "Available Quantity" %}</h5>
</td>
<td>
<h5>{% if item.quantity != available %}{% decimal available %} / {% endif %}{% decimal item.quantity %} {% if item.part.units %}{{ item.part.units }}{% endif %}</h5>
</td>
{% endif %}
</tr>
{% if item.belongs_to %}
<tr>
<td><span class='fas fa-box'></span></td>
<td>
{% trans "Installed In" %}
</td>
<td>
<a href="{% url 'stock-item-detail' item.belongs_to.id %}">{{ item.belongs_to }}</a>
</td>
</tr>
{% elif item.sales_order %}
<tr>
<td><span class='fas fa-user-tie'></span></td>
<td>{% trans "Sales Order" %}</td>
<td><a href="{% url 'so-detail' item.sales_order.id %}">{{ item.sales_order.reference }}</a> - <a href="{% url 'company-detail' item.sales_order.customer.id %}">{{ item.sales_order.customer.name }}</a></td>
</tr>
{% else %}
{% if allocated_to_sales_orders %}
<tr>
<td><span class='fas fa-truck'></span></td>
<td>{% trans "Allocated to Sales Orders" %}</td>
<td>{% decimal allocated_to_sales_orders %}</td>
</tr>
{% endif %}
{% if allocated_to_build_orders %}
<tr>
<td><span class='fas fa-tools'></span></td>
<td>{% trans "Allocated to Build Orders" %}</td>
<td>{% decimal allocated_to_build_orders %}</td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-map-marker-alt'></span></td>
<td>{% trans "Location" %}</td>
{% if item.location %}
<td><a href="{% url 'stock-location-detail' item.location.id %}">{{ item.location.name }}</a></td>
{% elif not item.customer %}
<td><em>{% trans "No location set" %}</em></td>
{% endif %}
</tr>
{% endif %}
{% if item.customer %}
<tr>
<td><span class='fas fa-user-tie'></span></td>
<td>{% trans "Customer" %}</td>
<td><a href="{% url 'company-detail' item.customer.id %}?display=assigned-stock">{{ item.customer.name }}</a></td>
</tr>
{% endif %}
{% if item.hasRequiredTests %}
<tr>
<td><span class='fas fa-vial'></span></td>
<td>{% trans "Tests" %}</td>
<td>
{{ item.requiredTestStatus.passed }} / {{ item.requiredTestStatus.total }}
{% if item.passedAllRequiredTests %}
<span class='fas fa-check-circle float-right icon-green'></span>
{% else %}
<span class='fas fa-times-circle float-right icon-red'></span>
{% endif %}
</td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-info'></span></td>
<td>{% trans "Status" %}</td>
<td>{% stock_status_label item.status %}</td>
</tr>
{% if item.expiry_date %}
<tr>
<td><span class='fas fa-calendar-alt{% if item.is_expired %} icon-red{% endif %}'></span></td>
<td>{% trans "Expiry Date" %}</td>
<td>
{% render_date item.expiry_date %}
{% if item.is_expired %}
<span title='{% blocktrans %}This StockItem expired on {{ item.expiry_date }}{% endblocktrans %}' class='badge rounded-pill bg-danger badge-right'>{% trans "Expired" %}</span>
{% elif item.is_stale %}
<span title='{% blocktrans %}This StockItem expires on {{ item.expiry_date }}{% endblocktrans %}' class='badge rounded-pill bg-warning badge-right'>{% trans "Stale" %}</span>
{% endif %}
</td>
</tr>
{% endif %}
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Last Updated" %}</td>
<td>{{ item.updated }}</td>
</tr>
<tr>
<td><span class='fas fa-calendar-alt'></span></td>
<td>{% trans "Last Stocktake" %}</td>
{% if item.stocktake_date %}
<td>{% render_date item.stocktake_date %} <span class='badge badge-right rounded-pill bg-dark'>{{ item.stocktake_user }}</span></td>
{% else %}
<td><em>{% trans "No stocktake performed" %}</em></td>
{% endif %}
</tr>
</table>
{% endblock details_right %}

View File

@ -94,6 +94,12 @@ class StockItemDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
data['item_owner'] = self.object.get_item_owner()
data['user_owns_item'] = self.object.check_ownership(self.request.user)
# Allocation information
data['allocated_to_sales_orders'] = self.object.sales_order_allocation_count()
data['allocated_to_build_orders'] = self.object.build_allocation_count()
data['allocated_to_orders'] = data['allocated_to_sales_orders'] + data['allocated_to_build_orders']
data['available'] = max(0, self.object.quantity - data['allocated_to_orders'])
return data
def get(self, request, *args, **kwargs):

View File

@ -129,7 +129,12 @@ function completeShipment(shipment_id, options={}) {
method: 'POST',
title: `{% trans "Complete Shipment" %} ${shipment.reference}`,
fields: {
tracking_number: {},
tracking_number: {
value: shipment.tracking_number,
},
shipment_date: {
value: moment().format('YYYY-MM-DD'),
}
},
preFormContent: html,
confirm: true,

View File

@ -1698,13 +1698,22 @@ function loadStockTable(table, options) {
sortable: true,
formatter: function(value, row) {
var val = parseFloat(value);
var val = '';
var available = Math.max(0, (row.quantity || 0) - (row.allocated || 0));
// If there is a single unit with a serial number, use the serial number
if (row.serial && row.quantity == 1) {
// If there is a single unit with a serial number, use the serial number
val = '# ' + row.serial;
} else if (row.quantity != available) {
// Some quantity is available, show available *and* quantity
var ava = +parseFloat(available).toFixed(5);
var tot = +parseFloat(row.quantity).toFixed(5);
val = `${ava} / ${tot}`;
} else {
val = +val.toFixed(5);
// Format floating point numbers with this one weird trick
val = +parseFloat(value).toFixed(5);
}
var html = renderLink(val, `/stock/item/${row.pk}/`);
@ -1719,16 +1728,7 @@ function loadStockTable(table, options) {
} else if (row.customer) {
// StockItem has been assigned to a customer
html += makeIconBadge('fa-user', '{% trans "Stock item assigned to customer" %}');
}
if (row.expired) {
html += makeIconBadge('fa-calendar-times icon-red', '{% trans "Stock item has expired" %}');
} else if (row.stale) {
html += makeIconBadge('fa-stopwatch', '{% trans "Stock item will expire soon" %}');
}
if (row.allocated) {
} else if (row.allocated) {
if (row.serial != null && row.quantity == 1) {
html += makeIconBadge('fa-bookmark icon-yellow', '{% trans "Serialized stock item has been allocated" %}');
} else if (row.allocated >= row.quantity) {
@ -1736,10 +1736,14 @@ function loadStockTable(table, options) {
} else {
html += makeIconBadge('fa-bookmark', '{% trans "Stock item has been partially allocated" %}');
}
} else if (row.belongs_to) {
html += makeIconBadge('fa-box', '{% trans "Stock item has been installed in another item" %}');
}
if (row.belongs_to) {
html += makeIconBadge('fa-box', '{% trans "Stock item has been installed in another item" %}');
if (row.expired) {
html += makeIconBadge('fa-calendar-times icon-red', '{% trans "Stock item has expired" %}');
} else if (row.stale) {
html += makeIconBadge('fa-stopwatch', '{% trans "Stock item will expire soon" %}');
}
// Special stock status codes

View File

@ -180,11 +180,23 @@ class OwnerModelTest(InvenTreeTestCase):
group_as_owner = Owner.get_owner(self.group)
self.assertEqual(type(group_as_owner), Owner)
# Check name
self.assertEqual(str(user_as_owner), 'testuser (user)')
# Get related owners (user + group)
related_owners = group_as_owner.get_related_owners(include_group=True)
self.assertTrue(user_as_owner in related_owners)
self.assertTrue(group_as_owner in related_owners)
# Get related owners (only user)
related_owners = group_as_owner.get_related_owners(include_group=False)
self.assertTrue(user_as_owner in related_owners)
self.assertFalse(group_as_owner in related_owners)
# Get related owners on user
related_owners = user_as_owner.get_related_owners()
self.assertEqual(related_owners, [user_as_owner])
# Check owner matching
owners = Owner.get_owners_matching_user(self.user)
self.assertEqual(owners, [user_as_owner, group_as_owner])