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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
|
||||||
|
|||||||
+1179
-1157
File diff suppressed because it is too large
Load Diff
+1179
-1157
File diff suppressed because it is too large
Load Diff
+1179
-1157
File diff suppressed because it is too large
Load Diff
+1294
-1239
File diff suppressed because it is too large
Load Diff
+1179
-1157
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1179
-1157
File diff suppressed because it is too large
Load Diff
+1179
-1157
File diff suppressed because it is too large
Load Diff
+1179
-1157
File diff suppressed because it is too large
Load Diff
+1191
-1169
File diff suppressed because it is too large
Load Diff
+1179
-1157
File diff suppressed because it is too large
Load Diff
+1179
-1157
File diff suppressed because it is too large
Load Diff
+1179
-1157
File diff suppressed because it is too large
Load Diff
+1179
-1157
File diff suppressed because it is too large
Load Diff
+1179
-1157
File diff suppressed because it is too large
Load Diff
+1179
-1157
File diff suppressed because it is too large
Load Diff
+1179
-1157
File diff suppressed because it is too large
Load Diff
+1179
-1157
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1179
-1157
File diff suppressed because it is too large
Load Diff
+1179
-1157
File diff suppressed because it is too large
Load Diff
+1179
-1157
File diff suppressed because it is too large
Load Diff
+1179
-1157
File diff suppressed because it is too large
Load Diff
+1179
-1157
File diff suppressed because it is too large
Load Diff
+1179
-1157
File diff suppressed because it is too large
Load Diff
@@ -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?
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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 %}
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
Reference in New Issue
Block a user