2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-05-23 09:35:30 +00:00

Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters
2022-05-24 20:32:30 +10:00
57 changed files with 30612 additions and 29510 deletions
+1 -1
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 os # pragma: no cover
import pathlib import pathlib # pragma: no cover
from InvenTree.helpers import InvenTreeTestCase # pragma: no cover from InvenTree.helpers import InvenTreeTestCase # pragma: no cover
+1
View File
@@ -910,6 +910,7 @@ if DEBUG or TESTING:
# Plugin test settings # Plugin test settings
PLUGIN_TESTING = get_setting('PLUGIN_TESTING', TESTING) # are plugins beeing tested? 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_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_RETRY = get_setting('PLUGIN_RETRY', 5) # how often should plugin loading be tried?
PLUGIN_FILE_CHECKED = False # Was the plugin file checked? PLUGIN_FILE_CHECKED = False # Was the plugin file checked?
+1 -1
View File
@@ -412,7 +412,7 @@ class CurrencyTests(TestCase):
update_successful = True update_successful = True
break break
else: else: # pragma: no cover
print("Exchange rate update failed - retrying") print("Exchange rate update failed - retrying")
time.sleep(1) time.sleep(1)
+2 -2
View File
@@ -76,7 +76,7 @@ class BulkNotificationMethodTests(BaseNotificationIntegrationTest):
def test_BulkNotificationMethod(self): def test_BulkNotificationMethod(self):
""" """
Ensure the implementation requirements are tested. 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): class WrongImplementation(BulkNotificationMethod):
@@ -94,7 +94,7 @@ class SingleNotificationMethodTests(BaseNotificationIntegrationTest):
def test_SingleNotificationMethod(self): def test_SingleNotificationMethod(self):
""" """
Ensure the implementation requirements are tested. 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): class WrongImplementation(SingleNotificationMethod):
+6 -2
View File
@@ -156,14 +156,14 @@ class SettingsTest(InvenTreeTestCase):
try: try:
self.run_settings_check(key, setting) 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}'") print(f"run_settings_check failed for global setting '{key}'")
raise exc raise exc
for key, setting in InvenTreeUserSetting.SETTINGS.items(): for key, setting in InvenTreeUserSetting.SETTINGS.items():
try: try:
self.run_settings_check(key, setting) 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}'") print(f"run_settings_check failed for user setting '{key}'")
raise exc raise exc
@@ -501,8 +501,12 @@ class PluginSettingsApiTest(InvenTreeAPITestCase):
"""List installed plugins via API""" """List installed plugins via API"""
url = reverse('api-plugin-list') url = reverse('api-plugin-list')
# Simple request
self.get(url, expected_code=200) self.get(url, expected_code=200)
# Request with filter
self.get(url, expected_code=200, data={'mixin': 'settings'})
def test_api_list(self): def test_api_list(self):
"""Test list URL""" """Test list URL"""
url = reverse('api-plugin-setting-list') url = reverse('api-plugin-setting-list')
+17 -15
View File
@@ -4,12 +4,11 @@ from django.conf import settings
from django.core.exceptions import FieldError, ValidationError from django.core.exceptions import FieldError, ValidationError
from django.http import HttpResponse, JsonResponse from django.http import HttpResponse, JsonResponse
from django.urls import include, re_path from django.urls import include, re_path
from django.utils.translation import gettext_lazy as _
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from PIL import Image from PIL import Image
from rest_framework import filters, generics from rest_framework import filters, generics
from rest_framework.response import Response from rest_framework.exceptions import NotFound
import common.models import common.models
import InvenTree.helpers import InvenTree.helpers
@@ -62,10 +61,14 @@ class LabelPrintMixin:
""" """
if not settings.PLUGINS_ENABLED: if not settings.PLUGINS_ENABLED:
return None return None # pragma: no cover
plugin_key = request.query_params.get('plugin', None) 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) plugin = registry.get_plugin(plugin_key)
if plugin: if plugin:
@@ -74,9 +77,10 @@ class LabelPrintMixin:
if config and config.active: if config and config.active:
# Only return the plugin if it is enabled! # Only return the plugin if it is enabled!
return plugin return plugin
else:
# No matches found raise ValidationError(f"Plugin '{plugin_key}' is not enabled")
return None else:
raise NotFound(f"Plugin '{plugin_key}' not found")
def print(self, request, items_to_print): 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 # Check the request to determine if the user has selected a label printing plugin
plugin = self.get_plugin(request) plugin = self.get_plugin(request)
if len(items_to_print) == 0: if len(items_to_print) == 0:
# No valid items provided, return an error message # 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 = [] outputs = []
@@ -281,7 +283,7 @@ class StockItemLabelList(LabelListView, StockItemLabelMixin):
# Filter string defined for the StockItemLabel object # Filter string defined for the StockItemLabel object
try: try:
filters = InvenTree.helpers.validateFilterString(label.filters) filters = InvenTree.helpers.validateFilterString(label.filters)
except ValidationError: except ValidationError: # pragma: no cover
continue continue
for item in items: for item in items:
@@ -300,7 +302,7 @@ class StockItemLabelList(LabelListView, StockItemLabelMixin):
if matches: if matches:
valid_label_ids.add(label.pk) valid_label_ids.add(label.pk)
else: else:
continue continue # pragma: no cover
# Reduce queryset to only valid matches # Reduce queryset to only valid matches
queryset = queryset.filter(pk__in=[pk for pk in valid_label_ids]) 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 # Filter string defined for the StockLocationLabel object
try: try:
filters = InvenTree.helpers.validateFilterString(label.filters) filters = InvenTree.helpers.validateFilterString(label.filters)
except: except: # pragma: no cover
# Skip if there was an error validating the filters... # Skip if there was an error validating the filters...
continue continue
@@ -432,7 +434,7 @@ class StockLocationLabelList(LabelListView, StockLocationLabelMixin):
if matches: if matches:
valid_label_ids.add(label.pk) valid_label_ids.add(label.pk)
else: else:
continue continue # pragma: no cover
# Reduce queryset to only valid matches # Reduce queryset to only valid matches
queryset = queryset.filter(pk__in=[pk for pk in valid_label_ids]) queryset = queryset.filter(pk__in=[pk for pk in valid_label_ids])
@@ -519,7 +521,7 @@ class PartLabelList(LabelListView, PartLabelMixin):
try: try:
filters = InvenTree.helpers.validateFilterString(label.filters) filters = InvenTree.helpers.validateFilterString(label.filters)
except ValidationError: except ValidationError: # pragma: no cover
continue continue
for part in parts: for part in parts:
+1 -1
View File
@@ -46,7 +46,7 @@ class LabelConfig(AppConfig):
try: try:
from .models import StockLocationLabel from .models import StockLocationLabel
assert bool(StockLocationLabel is not None) assert bool(StockLocationLabel is not None)
except AppRegistryNotReady: except AppRegistryNotReady: # pragma: no cover
# Database might not yet be ready # Database might not yet be ready
warnings.warn('Database was not ready for creating labels') warnings.warn('Database was not ready for creating labels')
return return
+4 -51
View File
@@ -8,7 +8,6 @@ import os
import sys import sys
from django.conf import settings from django.conf import settings
from django.core.exceptions import FieldError, ValidationError
from django.core.validators import FileExtensionValidator, MinValueValidator from django.core.validators import FileExtensionValidator, MinValueValidator
from django.db import models from django.db import models
from django.template import Context, Template from django.template import Context, Template
@@ -171,7 +170,7 @@ class LabelTemplate(models.Model):
Note: Override this in any subclass Note: Override this in any subclass
""" """
return {} return {} # pragma: no cover
def generate_filename(self, request, **kwargs): def generate_filename(self, request, **kwargs):
""" """
@@ -242,7 +241,7 @@ class StockItemLabel(LabelTemplate):
@staticmethod @staticmethod
def get_api_url(): def get_api_url():
return reverse('api-stockitem-label-list') return reverse('api-stockitem-label-list') # pragma: no cover
SUBDIR = "stockitem" 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): def get_context_data(self, request):
""" """
Generate context data for each provided StockItem Generate context data for each provided StockItem
@@ -302,7 +285,7 @@ class StockLocationLabel(LabelTemplate):
@staticmethod @staticmethod
def get_api_url(): def get_api_url():
return reverse('api-stocklocation-label-list') return reverse('api-stocklocation-label-list') # pragma: no cover
SUBDIR = "stocklocation" SUBDIR = "stocklocation"
@@ -314,21 +297,6 @@ class StockLocationLabel(LabelTemplate):
validate_stock_location_filters] 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): def get_context_data(self, request):
""" """
Generate context data for each provided StockLocation Generate context data for each provided StockLocation
@@ -349,7 +317,7 @@ class PartLabel(LabelTemplate):
@staticmethod @staticmethod
def get_api_url(): def get_api_url():
return reverse('api-part-label-list') return reverse('api-part-label-list') # pragma: no cover
SUBDIR = 'part' 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): def get_context_data(self, request):
""" """
Generate context data for each provided Part object Generate context data for each provided Part object
-36
View File
@@ -63,39 +63,3 @@ class TestReportTests(InvenTreeAPITestCase):
'items': [10, 11, 12], '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
+16 -7
View File
@@ -1205,14 +1205,23 @@ class SalesOrderShipment(models.Model):
def is_complete(self): def is_complete(self):
return self.shipment_date is not None return self.shipment_date is not None
def check_can_complete(self): def check_can_complete(self, raise_error=True):
if self.shipment_date: try:
# Shipment has already been sent! if self.shipment_date:
raise ValidationError(_("Shipment has already been sent")) # Shipment has already been sent!
raise ValidationError(_("Shipment has already been sent"))
if self.allocations.count() == 0: if self.allocations.count() == 0:
raise ValidationError(_("Shipment has no allocated stock items")) raise ValidationError(_("Shipment has no allocated stock items"))
except ValidationError as e:
if raise_error:
raise e
else:
return False
return True
@transaction.atomic @transaction.atomic
def complete_shipment(self, user, **kwargs): def complete_shipment(self, user, **kwargs):
@@ -1235,7 +1244,7 @@ class SalesOrderShipment(models.Model):
allocation.complete_allocation(user) allocation.complete_allocation(user)
# Update the "shipment" date # Update the "shipment" date
self.shipment_date = datetime.now() self.shipment_date = kwargs.get('shipment_date', datetime.now())
self.shipped_by = user self.shipped_by = user
# Was a tracking number provided? # Was a tracking number provided?
+12 -3
View File
@@ -2,6 +2,7 @@
JSON serializers for the Order API JSON serializers for the Order API
""" """
from datetime import datetime
from decimal import Decimal from decimal import Decimal
from django.core.exceptions import ValidationError as DjangoValidationError from django.core.exceptions import ValidationError as DjangoValidationError
@@ -899,6 +900,7 @@ class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer):
fields = [ fields = [
'tracking_number', 'tracking_number',
'shipment_date',
] ]
def validate(self, data): def validate(self, data):
@@ -910,7 +912,7 @@ class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer):
if not shipment: if not shipment:
raise ValidationError(_("No shipment details provided")) raise ValidationError(_("No shipment details provided"))
shipment.check_can_complete() shipment.check_can_complete(raise_error=True)
return data return data
@@ -927,9 +929,16 @@ class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer):
user = request.user user = request.user
# Extract provided tracking number (optional) # 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): class SalesOrderShipmentAllocationItemSerializer(serializers.Serializer):
+94
View File
@@ -5,6 +5,7 @@ Tests for the Order API
import io import io
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.core.exceptions import ValidationError
from django.urls import reverse from django.urls import reverse
from rest_framework import status from rest_framework import status
@@ -1275,3 +1276,96 @@ class SalesOrderAllocateTest(OrderTest):
for line in self.order.lines.all(): for line in self.order.lines.all():
self.assertEqual(line.allocations.count(), 1) 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())
+2 -2
View File
@@ -58,7 +58,7 @@ class TemplateTagTest(InvenTreeTestCase):
def test_hash(self): def test_hash(self):
result_hash = inventree_extras.inventree_commit_hash() 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 # Testing inside docker environment *may* return an empty git commit hash
# In such a case, skip this check # In such a case, skip this check
pass pass
@@ -67,7 +67,7 @@ class TemplateTagTest(InvenTreeTestCase):
def test_date(self): def test_date(self):
d = inventree_extras.inventree_commit_date() 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 # Testing inside docker environment *may* return an empty git commit hash
# In such a case, skip this check # In such a case, skip this check
pass pass
+2 -2
View File
@@ -20,7 +20,7 @@ class PluginAppConfig(AppConfig):
def ready(self): def ready(self):
if settings.PLUGINS_ENABLED: if settings.PLUGINS_ENABLED:
if not canAppAccessDatabase(allow_test=True): if not canAppAccessDatabase(allow_test=True):
logger.info("Skipping plugin loading sequence") logger.info("Skipping plugin loading sequence") # pragma: no cover
else: else:
logger.info('Loading InvenTree plugins') 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') log_error(_('Your enviroment has an outdated git version. This prevents InvenTree from loading plugin details.'), 'load')
else: else:
logger.info("Plugins not enabled - skipping loading sequence") logger.info("Plugins not enabled - skipping loading sequence") # pragma: no cover
+5 -4
View File
@@ -26,9 +26,10 @@ def trigger_event(event, *args, **kwargs):
if not settings.PLUGINS_ENABLED: if not settings.PLUGINS_ENABLED:
# Do nothing if plugins are not 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") logger.debug(f"Ignoring triggered event '{event}' - database not ready")
return return
@@ -91,7 +92,7 @@ def process_event(plugin_slug, event, *args, **kwargs):
plugin = registry.plugins.get(plugin_slug, 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}'") logger.error(f"Could not find matching plugin for '{plugin_slug}'")
return return
@@ -106,7 +107,7 @@ def allow_table_event(table_name):
if isImportingData(): if isImportingData():
# Prevent table events during the data import process # Prevent table events during the data import process
return False return False # pragma: no cover
table_name = table_name.lower().strip() table_name = table_name.lower().strip()
+4 -4
View File
@@ -57,10 +57,10 @@ class SettingsMixin:
except (OperationalError, ProgrammingError): # pragma: no cover except (OperationalError, ProgrammingError): # pragma: no cover
plugin = None plugin = None
if not plugin: if not plugin: # pragma: no cover
# Cannot find associated plugin model, return # Cannot find associated plugin model, return
logger.error(f"Plugin configuration not found for plugin '{self.slug}'") logger.error(f"Plugin configuration not found for plugin '{self.slug}'")
return # pragma: no cover return
PluginSetting.set_setting(key, value, user, plugin=plugin) PluginSetting.set_setting(key, value, user, plugin=plugin)
@@ -541,7 +541,7 @@ class PanelMixin:
def get_custom_panels(self, view, request): def get_custom_panels(self, view, request):
""" This method *must* be implemented by the plugin class """ """ 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): def get_panel_context(self, view, request, context):
""" """
@@ -559,7 +559,7 @@ class PanelMixin:
try: try:
context['object'] = view.get_object() context['object'] = view.get_object()
except AttributeError: except AttributeError: # pragma: no cover
pass pass
return context return context
@@ -8,6 +8,7 @@ from error_report.models import Error
from InvenTree.helpers import InvenTreeTestCase from InvenTree.helpers import InvenTreeTestCase
from plugin import InvenTreePlugin from plugin import InvenTreePlugin
from plugin.base.integration.mixins import PanelMixin
from plugin.helpers import MixinNotImplementedError from plugin.helpers import MixinNotImplementedError
from plugin.mixins import (APICallMixin, AppMixin, NavigationMixin, from plugin.mixins import (APICallMixin, AppMixin, NavigationMixin,
SettingsMixin, UrlsMixin) SettingsMixin, UrlsMixin)
@@ -324,7 +325,7 @@ class PanelMixinTests(InvenTreeTestCase):
urls = [ urls = [
reverse('part-detail', kwargs={'pk': 1}), reverse('part-detail', kwargs={'pk': 1}),
reverse('stock-item-detail', kwargs={'pk': 2}), 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) plugin.set_setting('ENABLE_HELLO_WORLD', False)
@@ -379,3 +380,13 @@ class PanelMixinTests(InvenTreeTestCase):
# Assert that each request threw an error # Assert that each request threw an error
self.assertEqual(Error.objects.count(), n_errors + len(urls)) 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')
+2 -2
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) 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}'") logger.error(f"Could not find matching plugin for '{plugin_slug}'")
return return
try: try:
plugin.print_label(label_image, width=label_instance.width, height=label_instance.height) 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 # Plugin threw an error - notify the user who attempted to print
ctx = { ctx = {
@@ -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')
+3 -3
View File
@@ -2,7 +2,7 @@
import logging import logging
from plugin.helpers import MixinImplementationError from plugin.helpers import MixinNotImplementedError
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@@ -58,7 +58,7 @@ class LocateMixin:
if item.in_stock and item.location is not None: if item.in_stock and item.location is not None:
self.locate_stock_location(item.location.pk) 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") logger.warning("LocateMixin: StockItem pk={item_pk} not found")
pass pass
@@ -71,4 +71,4 @@ class LocateMixin:
Note: The default implementation here does nothing! Note: The default implementation here does nothing!
""" """
raise MixinImplementationError raise MixinNotImplementedError
+16 -1
View File
@@ -5,7 +5,8 @@ Unit tests for the 'locate' plugin mixin class
from django.urls import reverse from django.urls import reverse
from InvenTree.api_tester import InvenTreeAPITestCase 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 from stock.models import StockItem, StockLocation
@@ -145,3 +146,17 @@ class LocatePluginTests(InvenTreeAPITestCase):
# Item metadata should have been altered! # Item metadata should have been altered!
self.assertTrue(location.metadata['located']) 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)
@@ -2,6 +2,10 @@
Sample plugin which responds to events Sample plugin which responds to events
""" """
import warnings
from django.conf import settings
from plugin import InvenTreePlugin from plugin import InvenTreePlugin
from plugin.mixins import EventMixin from plugin.mixins import EventMixin
@@ -21,3 +25,7 @@ class EventPluginSample(EventMixin, InvenTreePlugin):
print(f"Processing triggered event: '{event}'") print(f"Processing triggered event: '{event}'")
print("args:", str(args)) print("args:", str(args))
print("kwargs:", str(kwargs)) print("kwargs:", str(kwargs))
# Issue warning that we can test for
if settings.PLUGIN_TESTING:
warnings.warn(f'Event `{event}` triggered')
@@ -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')
@@ -120,7 +120,7 @@ class CustomPanelSample(PanelMixin, SettingsMixin, InvenTreePlugin):
'icon': 'fa-user', 'icon': 'fa-user',
'content_template': 'panel_demo/childless.html', # Note that the panel content is rendered using a template file! 'content_template': 'panel_demo/childless.html', # Note that the panel content is rendered using a template file!
}) })
except: except: # pragma: no cover
pass pass
return panels return panels
@@ -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")
@@ -37,7 +37,7 @@ class SampleLocatePlugin(LocateMixin, InvenTreePlugin):
# Tag metadata # Tag metadata
item.set_metadata('located', True) 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!") logger.error(f"StockItem ID {item_pk} does not exist!")
def locate_stock_location(self, location_pk): def locate_stock_location(self, location_pk):
@@ -53,5 +53,5 @@ class SampleLocatePlugin(LocateMixin, InvenTreePlugin):
# Tag metadata # Tag metadata
location.set_metadata('located', True) 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!") logger.error(f"Location ID {location_pk} does not exist!")
@@ -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)
+198 -170
View File
@@ -156,158 +156,7 @@
{% endif %} {% endif %}
</td> </td>
</tr> </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 %} {% if item.uid %}
<tr> <tr>
<td><span class='fas fa-barcode'></span></td> <td><span class='fas fa-barcode'></span></td>
@@ -322,13 +171,6 @@
<td>{{ item.batch }}</td> <td>{{ item.batch }}</td>
</tr> </tr>
{% endif %} {% 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 %} {% if item.build %}
<tr> <tr>
<td><span class='fas fa-tools'></span></td> <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> <td><a href="{% url 'supplier-part-detail' item.supplier_part.id %}">{{ item.supplier_part.SKU }}</a></td>
</tr> </tr>
{% endif %} {% endif %}
{% if item.hasRequiredTests %} {% if item.packaging %}
<tr> <tr>
<td><span class='fas fa-vial'></span></td> <td><span class='fas fa-cube'></span></td>
<td>{% trans "Tests" %}</td> <td>{% trans "Packaging" %}</td>
<td> <td>{{ item.packaging }}</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> </tr>
{% endif %} {% endif %}
{% if ownership_enabled and item_owner %} {% if ownership_enabled and item_owner %}
@@ -425,6 +260,199 @@
</td> </td>
</tr> </tr>
{% endif %} {% 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> </table>
{% endblock details_right %} {% endblock details_right %}
+6
View File
@@ -94,6 +94,12 @@ class StockItemDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
data['item_owner'] = self.object.get_item_owner() data['item_owner'] = self.object.get_item_owner()
data['user_owns_item'] = self.object.check_ownership(self.request.user) 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 return data
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
+6 -1
View File
@@ -129,7 +129,12 @@ function completeShipment(shipment_id, options={}) {
method: 'POST', method: 'POST',
title: `{% trans "Complete Shipment" %} ${shipment.reference}`, title: `{% trans "Complete Shipment" %} ${shipment.reference}`,
fields: { fields: {
tracking_number: {}, tracking_number: {
value: shipment.tracking_number,
},
shipment_date: {
value: moment().format('YYYY-MM-DD'),
}
}, },
preFormContent: html, preFormContent: html,
confirm: true, confirm: true,
+19 -15
View File
@@ -1698,13 +1698,22 @@ function loadStockTable(table, options) {
sortable: true, sortable: true,
formatter: function(value, row) { 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 (row.serial && row.quantity == 1) {
// If there is a single unit with a serial number, use the serial number
val = '# ' + row.serial; 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 { } 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}/`); var html = renderLink(val, `/stock/item/${row.pk}/`);
@@ -1719,16 +1728,7 @@ function loadStockTable(table, options) {
} else if (row.customer) { } else if (row.customer) {
// StockItem has been assigned to a customer // StockItem has been assigned to a customer
html += makeIconBadge('fa-user', '{% trans "Stock item assigned to customer" %}'); html += makeIconBadge('fa-user', '{% trans "Stock item assigned to customer" %}');
} } else if (row.allocated) {
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) {
if (row.serial != null && row.quantity == 1) { if (row.serial != null && row.quantity == 1) {
html += makeIconBadge('fa-bookmark icon-yellow', '{% trans "Serialized stock item has been allocated" %}'); html += makeIconBadge('fa-bookmark icon-yellow', '{% trans "Serialized stock item has been allocated" %}');
} else if (row.allocated >= row.quantity) { } else if (row.allocated >= row.quantity) {
@@ -1736,10 +1736,14 @@ function loadStockTable(table, options) {
} else { } else {
html += makeIconBadge('fa-bookmark', '{% trans "Stock item has been partially allocated" %}'); 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) { if (row.expired) {
html += makeIconBadge('fa-box', '{% trans "Stock item has been installed in another item" %}'); 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 // Special stock status codes
+12
View File
@@ -180,11 +180,23 @@ class OwnerModelTest(InvenTreeTestCase):
group_as_owner = Owner.get_owner(self.group) group_as_owner = Owner.get_owner(self.group)
self.assertEqual(type(group_as_owner), Owner) self.assertEqual(type(group_as_owner), Owner)
# Check name
self.assertEqual(str(user_as_owner), 'testuser (user)')
# Get related owners (user + group) # Get related owners (user + group)
related_owners = group_as_owner.get_related_owners(include_group=True) related_owners = group_as_owner.get_related_owners(include_group=True)
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)
# 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 # Check owner matching
owners = Owner.get_owners_matching_user(self.user) owners = Owner.get_owners_matching_user(self.user)
self.assertEqual(owners, [user_as_owner, group_as_owner]) self.assertEqual(owners, [user_as_owner, group_as_owner])