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 pathlib
|
||||
import pathlib # pragma: no cover
|
||||
|
||||
from InvenTree.helpers import InvenTreeTestCase # pragma: no cover
|
||||
|
||||
|
||||
@@ -910,6 +910,7 @@ if DEBUG or TESTING:
|
||||
# Plugin test settings
|
||||
PLUGIN_TESTING = get_setting('PLUGIN_TESTING', TESTING) # are plugins beeing tested?
|
||||
PLUGIN_TESTING_SETUP = get_setting('PLUGIN_TESTING_SETUP', False) # load plugins from setup hooks in testing?
|
||||
PLUGIN_TESTING_EVENTS = False # Flag if events are tested right now
|
||||
PLUGIN_RETRY = get_setting('PLUGIN_RETRY', 5) # how often should plugin loading be tried?
|
||||
PLUGIN_FILE_CHECKED = False # Was the plugin file checked?
|
||||
|
||||
|
||||
@@ -412,7 +412,7 @@ class CurrencyTests(TestCase):
|
||||
update_successful = True
|
||||
break
|
||||
|
||||
else:
|
||||
else: # pragma: no cover
|
||||
print("Exchange rate update failed - retrying")
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ class BulkNotificationMethodTests(BaseNotificationIntegrationTest):
|
||||
def test_BulkNotificationMethod(self):
|
||||
"""
|
||||
Ensure the implementation requirements are tested.
|
||||
NotImplementedError needs to raise if the send_bulk() method is not set.
|
||||
MixinNotImplementedError needs to raise if the send_bulk() method is not set.
|
||||
"""
|
||||
|
||||
class WrongImplementation(BulkNotificationMethod):
|
||||
@@ -94,7 +94,7 @@ class SingleNotificationMethodTests(BaseNotificationIntegrationTest):
|
||||
def test_SingleNotificationMethod(self):
|
||||
"""
|
||||
Ensure the implementation requirements are tested.
|
||||
NotImplementedError needs to raise if the send() method is not set.
|
||||
MixinNotImplementedError needs to raise if the send() method is not set.
|
||||
"""
|
||||
|
||||
class WrongImplementation(SingleNotificationMethod):
|
||||
|
||||
@@ -156,14 +156,14 @@ class SettingsTest(InvenTreeTestCase):
|
||||
|
||||
try:
|
||||
self.run_settings_check(key, setting)
|
||||
except Exception as exc:
|
||||
except Exception as exc: # pragma: no cover
|
||||
print(f"run_settings_check failed for global setting '{key}'")
|
||||
raise exc
|
||||
|
||||
for key, setting in InvenTreeUserSetting.SETTINGS.items():
|
||||
try:
|
||||
self.run_settings_check(key, setting)
|
||||
except Exception as exc:
|
||||
except Exception as exc: # pragma: no cover
|
||||
print(f"run_settings_check failed for user setting '{key}'")
|
||||
raise exc
|
||||
|
||||
@@ -501,8 +501,12 @@ class PluginSettingsApiTest(InvenTreeAPITestCase):
|
||||
"""List installed plugins via API"""
|
||||
url = reverse('api-plugin-list')
|
||||
|
||||
# Simple request
|
||||
self.get(url, expected_code=200)
|
||||
|
||||
# Request with filter
|
||||
self.get(url, expected_code=200, data={'mixin': 'settings'})
|
||||
|
||||
def test_api_list(self):
|
||||
"""Test list URL"""
|
||||
url = reverse('api-plugin-setting-list')
|
||||
|
||||
+17
-15
@@ -4,12 +4,11 @@ from django.conf import settings
|
||||
from django.core.exceptions import FieldError, ValidationError
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.urls import include, re_path
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from PIL import Image
|
||||
from rest_framework import filters, generics
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.exceptions import NotFound
|
||||
|
||||
import common.models
|
||||
import InvenTree.helpers
|
||||
@@ -62,10 +61,14 @@ class LabelPrintMixin:
|
||||
"""
|
||||
|
||||
if not settings.PLUGINS_ENABLED:
|
||||
return None
|
||||
return None # pragma: no cover
|
||||
|
||||
plugin_key = request.query_params.get('plugin', None)
|
||||
|
||||
# No plugin provided, and that's OK
|
||||
if plugin_key is None:
|
||||
return None
|
||||
|
||||
plugin = registry.get_plugin(plugin_key)
|
||||
|
||||
if plugin:
|
||||
@@ -74,9 +77,10 @@ class LabelPrintMixin:
|
||||
if config and config.active:
|
||||
# Only return the plugin if it is enabled!
|
||||
return plugin
|
||||
|
||||
# No matches found
|
||||
return None
|
||||
else:
|
||||
raise ValidationError(f"Plugin '{plugin_key}' is not enabled")
|
||||
else:
|
||||
raise NotFound(f"Plugin '{plugin_key}' not found")
|
||||
|
||||
def print(self, request, items_to_print):
|
||||
"""
|
||||
@@ -85,13 +89,11 @@ class LabelPrintMixin:
|
||||
|
||||
# Check the request to determine if the user has selected a label printing plugin
|
||||
plugin = self.get_plugin(request)
|
||||
|
||||
if len(items_to_print) == 0:
|
||||
# No valid items provided, return an error message
|
||||
data = {
|
||||
'error': _('No valid objects provided to template'),
|
||||
}
|
||||
|
||||
return Response(data, status=400)
|
||||
raise ValidationError('No valid objects provided to label template')
|
||||
|
||||
outputs = []
|
||||
|
||||
@@ -281,7 +283,7 @@ class StockItemLabelList(LabelListView, StockItemLabelMixin):
|
||||
# Filter string defined for the StockItemLabel object
|
||||
try:
|
||||
filters = InvenTree.helpers.validateFilterString(label.filters)
|
||||
except ValidationError:
|
||||
except ValidationError: # pragma: no cover
|
||||
continue
|
||||
|
||||
for item in items:
|
||||
@@ -300,7 +302,7 @@ class StockItemLabelList(LabelListView, StockItemLabelMixin):
|
||||
if matches:
|
||||
valid_label_ids.add(label.pk)
|
||||
else:
|
||||
continue
|
||||
continue # pragma: no cover
|
||||
|
||||
# Reduce queryset to only valid matches
|
||||
queryset = queryset.filter(pk__in=[pk for pk in valid_label_ids])
|
||||
@@ -412,7 +414,7 @@ class StockLocationLabelList(LabelListView, StockLocationLabelMixin):
|
||||
# Filter string defined for the StockLocationLabel object
|
||||
try:
|
||||
filters = InvenTree.helpers.validateFilterString(label.filters)
|
||||
except:
|
||||
except: # pragma: no cover
|
||||
# Skip if there was an error validating the filters...
|
||||
continue
|
||||
|
||||
@@ -432,7 +434,7 @@ class StockLocationLabelList(LabelListView, StockLocationLabelMixin):
|
||||
if matches:
|
||||
valid_label_ids.add(label.pk)
|
||||
else:
|
||||
continue
|
||||
continue # pragma: no cover
|
||||
|
||||
# Reduce queryset to only valid matches
|
||||
queryset = queryset.filter(pk__in=[pk for pk in valid_label_ids])
|
||||
@@ -519,7 +521,7 @@ class PartLabelList(LabelListView, PartLabelMixin):
|
||||
|
||||
try:
|
||||
filters = InvenTree.helpers.validateFilterString(label.filters)
|
||||
except ValidationError:
|
||||
except ValidationError: # pragma: no cover
|
||||
continue
|
||||
|
||||
for part in parts:
|
||||
|
||||
@@ -46,7 +46,7 @@ class LabelConfig(AppConfig):
|
||||
try:
|
||||
from .models import StockLocationLabel
|
||||
assert bool(StockLocationLabel is not None)
|
||||
except AppRegistryNotReady:
|
||||
except AppRegistryNotReady: # pragma: no cover
|
||||
# Database might not yet be ready
|
||||
warnings.warn('Database was not ready for creating labels')
|
||||
return
|
||||
|
||||
@@ -8,7 +8,6 @@ import os
|
||||
import sys
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import FieldError, ValidationError
|
||||
from django.core.validators import FileExtensionValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.template import Context, Template
|
||||
@@ -171,7 +170,7 @@ class LabelTemplate(models.Model):
|
||||
Note: Override this in any subclass
|
||||
"""
|
||||
|
||||
return {}
|
||||
return {} # pragma: no cover
|
||||
|
||||
def generate_filename(self, request, **kwargs):
|
||||
"""
|
||||
@@ -242,7 +241,7 @@ class StockItemLabel(LabelTemplate):
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
return reverse('api-stockitem-label-list')
|
||||
return reverse('api-stockitem-label-list') # pragma: no cover
|
||||
|
||||
SUBDIR = "stockitem"
|
||||
|
||||
@@ -255,22 +254,6 @@ class StockItemLabel(LabelTemplate):
|
||||
]
|
||||
)
|
||||
|
||||
def matches_stock_item(self, item):
|
||||
"""
|
||||
Test if this label template matches a given StockItem object
|
||||
"""
|
||||
|
||||
try:
|
||||
filters = validateFilterString(self.filters)
|
||||
items = stock.models.StockItem.objects.filter(**filters)
|
||||
except (ValidationError, FieldError):
|
||||
# If an error exists with the "filters" field, return False
|
||||
return False
|
||||
|
||||
items = items.filter(pk=item.pk)
|
||||
|
||||
return items.exists()
|
||||
|
||||
def get_context_data(self, request):
|
||||
"""
|
||||
Generate context data for each provided StockItem
|
||||
@@ -302,7 +285,7 @@ class StockLocationLabel(LabelTemplate):
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
return reverse('api-stocklocation-label-list')
|
||||
return reverse('api-stocklocation-label-list') # pragma: no cover
|
||||
|
||||
SUBDIR = "stocklocation"
|
||||
|
||||
@@ -314,21 +297,6 @@ class StockLocationLabel(LabelTemplate):
|
||||
validate_stock_location_filters]
|
||||
)
|
||||
|
||||
def matches_stock_location(self, location):
|
||||
"""
|
||||
Test if this label template matches a given StockLocation object
|
||||
"""
|
||||
|
||||
try:
|
||||
filters = validateFilterString(self.filters)
|
||||
locs = stock.models.StockLocation.objects.filter(**filters)
|
||||
except (ValidationError, FieldError):
|
||||
return False
|
||||
|
||||
locs = locs.filter(pk=location.pk)
|
||||
|
||||
return locs.exists()
|
||||
|
||||
def get_context_data(self, request):
|
||||
"""
|
||||
Generate context data for each provided StockLocation
|
||||
@@ -349,7 +317,7 @@ class PartLabel(LabelTemplate):
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
return reverse('api-part-label-list')
|
||||
return reverse('api-part-label-list') # pragma: no cover
|
||||
|
||||
SUBDIR = 'part'
|
||||
|
||||
@@ -362,21 +330,6 @@ class PartLabel(LabelTemplate):
|
||||
]
|
||||
)
|
||||
|
||||
def matches_part(self, part):
|
||||
"""
|
||||
Test if this label template matches a given Part object
|
||||
"""
|
||||
|
||||
try:
|
||||
filters = validateFilterString(self.filters)
|
||||
parts = part.models.Part.objects.filter(**filters)
|
||||
except (ValidationError, FieldError):
|
||||
return False
|
||||
|
||||
parts = parts.filter(pk=part.pk)
|
||||
|
||||
return parts.exists()
|
||||
|
||||
def get_context_data(self, request):
|
||||
"""
|
||||
Generate context data for each provided Part object
|
||||
|
||||
@@ -63,39 +63,3 @@ class TestReportTests(InvenTreeAPITestCase):
|
||||
'items': [10, 11, 12],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TestLabels(InvenTreeAPITestCase):
|
||||
"""
|
||||
Tests for the label APIs
|
||||
"""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
'part',
|
||||
'location',
|
||||
'stock',
|
||||
]
|
||||
|
||||
roles = [
|
||||
'stock.view',
|
||||
'stock_location.view',
|
||||
]
|
||||
|
||||
def do_list(self, filters={}):
|
||||
|
||||
response = self.client.get(self.list_url, filters, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
return response.data
|
||||
|
||||
def test_lists(self):
|
||||
self.list_url = reverse('api-stockitem-label-list')
|
||||
self.do_list()
|
||||
|
||||
self.list_url = reverse('api-stocklocation-label-list')
|
||||
self.do_list()
|
||||
|
||||
self.list_url = reverse('api-part-label-list')
|
||||
self.do_list()
|
||||
|
||||
+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):
|
||||
return self.shipment_date is not None
|
||||
|
||||
def check_can_complete(self):
|
||||
def check_can_complete(self, raise_error=True):
|
||||
|
||||
if self.shipment_date:
|
||||
# Shipment has already been sent!
|
||||
raise ValidationError(_("Shipment has already been sent"))
|
||||
try:
|
||||
if self.shipment_date:
|
||||
# Shipment has already been sent!
|
||||
raise ValidationError(_("Shipment has already been sent"))
|
||||
|
||||
if self.allocations.count() == 0:
|
||||
raise ValidationError(_("Shipment has no allocated stock items"))
|
||||
if self.allocations.count() == 0:
|
||||
raise ValidationError(_("Shipment has no allocated stock items"))
|
||||
|
||||
except ValidationError as e:
|
||||
if raise_error:
|
||||
raise e
|
||||
else:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@transaction.atomic
|
||||
def complete_shipment(self, user, **kwargs):
|
||||
@@ -1235,7 +1244,7 @@ class SalesOrderShipment(models.Model):
|
||||
allocation.complete_allocation(user)
|
||||
|
||||
# Update the "shipment" date
|
||||
self.shipment_date = datetime.now()
|
||||
self.shipment_date = kwargs.get('shipment_date', datetime.now())
|
||||
self.shipped_by = user
|
||||
|
||||
# Was a tracking number provided?
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
JSON serializers for the Order API
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
@@ -899,6 +900,7 @@ class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer):
|
||||
|
||||
fields = [
|
||||
'tracking_number',
|
||||
'shipment_date',
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
@@ -910,7 +912,7 @@ class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer):
|
||||
if not shipment:
|
||||
raise ValidationError(_("No shipment details provided"))
|
||||
|
||||
shipment.check_can_complete()
|
||||
shipment.check_can_complete(raise_error=True)
|
||||
|
||||
return data
|
||||
|
||||
@@ -927,9 +929,16 @@ class SalesOrderShipmentCompleteSerializer(serializers.ModelSerializer):
|
||||
user = request.user
|
||||
|
||||
# Extract provided tracking number (optional)
|
||||
tracking_number = data.get('tracking_number', None)
|
||||
tracking_number = data.get('tracking_number', shipment.tracking_number)
|
||||
|
||||
shipment.complete_shipment(user, tracking_number=tracking_number)
|
||||
# Extract shipping date (defaults to today's date)
|
||||
shipment_date = data.get('shipment_date', datetime.now())
|
||||
|
||||
shipment.complete_shipment(
|
||||
user,
|
||||
tracking_number=tracking_number,
|
||||
shipment_date=shipment_date,
|
||||
)
|
||||
|
||||
|
||||
class SalesOrderShipmentAllocationItemSerializer(serializers.Serializer):
|
||||
|
||||
@@ -5,6 +5,7 @@ Tests for the Order API
|
||||
import io
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.urls import reverse
|
||||
|
||||
from rest_framework import status
|
||||
@@ -1275,3 +1276,96 @@ class SalesOrderAllocateTest(OrderTest):
|
||||
|
||||
for line in self.order.lines.all():
|
||||
self.assertEqual(line.allocations.count(), 1)
|
||||
|
||||
def test_shipment_complete(self):
|
||||
"""Test that we can complete a shipment via the API"""
|
||||
|
||||
url = reverse('api-so-shipment-ship', kwargs={'pk': self.shipment.pk})
|
||||
|
||||
self.assertFalse(self.shipment.is_complete())
|
||||
self.assertFalse(self.shipment.check_can_complete(raise_error=False))
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
self.shipment.check_can_complete()
|
||||
|
||||
# Attempting to complete this shipment via the API should fail
|
||||
response = self.post(
|
||||
url, {},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
self.assertIn('Shipment has no allocated stock items', str(response.data))
|
||||
|
||||
# Allocate stock against this shipment
|
||||
line = self.order.lines.first()
|
||||
part = line.part
|
||||
|
||||
models.SalesOrderAllocation.objects.create(
|
||||
shipment=self.shipment,
|
||||
line=line,
|
||||
item=part.stock_items.last(),
|
||||
quantity=5
|
||||
)
|
||||
|
||||
# Shipment should now be able to be completed
|
||||
self.assertTrue(self.shipment.check_can_complete())
|
||||
|
||||
# Attempt with an invalid date
|
||||
response = self.post(
|
||||
url,
|
||||
{
|
||||
'shipment_date': 'asfasd',
|
||||
},
|
||||
expected_code=400,
|
||||
)
|
||||
|
||||
self.assertIn('Date has wrong format', str(response.data))
|
||||
|
||||
response = self.post(
|
||||
url,
|
||||
{
|
||||
'tracking_number': 'TRK12345',
|
||||
'shipment_date': '2020-12-05',
|
||||
},
|
||||
expected_code=201,
|
||||
)
|
||||
|
||||
self.shipment.refresh_from_db()
|
||||
|
||||
self.assertTrue(self.shipment.is_complete())
|
||||
self.assertEqual(self.shipment.tracking_number, 'TRK12345')
|
||||
|
||||
def test_sales_order_shipment_list(self):
|
||||
|
||||
url = reverse('api-so-shipment-list')
|
||||
|
||||
# Create some new shipments via the API
|
||||
for order in models.SalesOrder.objects.all():
|
||||
|
||||
for idx in range(3):
|
||||
self.post(
|
||||
url,
|
||||
{
|
||||
'order': order.pk,
|
||||
'reference': f"SH{idx + 1}",
|
||||
'tracking_number': f"TRK_{order.pk}_{idx}"
|
||||
},
|
||||
expected_code=201
|
||||
)
|
||||
|
||||
# Filter API by order
|
||||
response = self.get(
|
||||
url,
|
||||
{
|
||||
'order': order.pk,
|
||||
},
|
||||
expected_code=200,
|
||||
)
|
||||
|
||||
# 3 shipments returned for each SalesOrder instance
|
||||
self.assertGreaterEqual(len(response.data), 3)
|
||||
|
||||
# List *all* shipments
|
||||
response = self.get(url, expected_code=200)
|
||||
|
||||
self.assertEqual(len(response.data), 1 + 3 * models.SalesOrder.objects.count())
|
||||
|
||||
@@ -58,7 +58,7 @@ class TemplateTagTest(InvenTreeTestCase):
|
||||
|
||||
def test_hash(self):
|
||||
result_hash = inventree_extras.inventree_commit_hash()
|
||||
if settings.DOCKER:
|
||||
if settings.DOCKER: # pragma: no cover
|
||||
# Testing inside docker environment *may* return an empty git commit hash
|
||||
# In such a case, skip this check
|
||||
pass
|
||||
@@ -67,7 +67,7 @@ class TemplateTagTest(InvenTreeTestCase):
|
||||
|
||||
def test_date(self):
|
||||
d = inventree_extras.inventree_commit_date()
|
||||
if settings.DOCKER:
|
||||
if settings.DOCKER: # pragma: no cover
|
||||
# Testing inside docker environment *may* return an empty git commit hash
|
||||
# In such a case, skip this check
|
||||
pass
|
||||
|
||||
@@ -20,7 +20,7 @@ class PluginAppConfig(AppConfig):
|
||||
def ready(self):
|
||||
if settings.PLUGINS_ENABLED:
|
||||
if not canAppAccessDatabase(allow_test=True):
|
||||
logger.info("Skipping plugin loading sequence")
|
||||
logger.info("Skipping plugin loading sequence") # pragma: no cover
|
||||
else:
|
||||
logger.info('Loading InvenTree plugins')
|
||||
|
||||
@@ -48,4 +48,4 @@ class PluginAppConfig(AppConfig):
|
||||
log_error(_('Your enviroment has an outdated git version. This prevents InvenTree from loading plugin details.'), 'load')
|
||||
|
||||
else:
|
||||
logger.info("Plugins not enabled - skipping loading sequence")
|
||||
logger.info("Plugins not enabled - skipping loading sequence") # pragma: no cover
|
||||
|
||||
@@ -26,9 +26,10 @@ def trigger_event(event, *args, **kwargs):
|
||||
|
||||
if not settings.PLUGINS_ENABLED:
|
||||
# Do nothing if plugins are not enabled
|
||||
return
|
||||
return # pragma: no cover
|
||||
|
||||
if not canAppAccessDatabase():
|
||||
# Make sure the database can be accessed and is not beeing tested rn
|
||||
if not canAppAccessDatabase() and not settings.PLUGIN_TESTING_EVENTS:
|
||||
logger.debug(f"Ignoring triggered event '{event}' - database not ready")
|
||||
return
|
||||
|
||||
@@ -91,7 +92,7 @@ def process_event(plugin_slug, event, *args, **kwargs):
|
||||
|
||||
plugin = registry.plugins.get(plugin_slug, None)
|
||||
|
||||
if plugin is None:
|
||||
if plugin is None: # pragma: no cover
|
||||
logger.error(f"Could not find matching plugin for '{plugin_slug}'")
|
||||
return
|
||||
|
||||
@@ -106,7 +107,7 @@ def allow_table_event(table_name):
|
||||
|
||||
if isImportingData():
|
||||
# Prevent table events during the data import process
|
||||
return False
|
||||
return False # pragma: no cover
|
||||
|
||||
table_name = table_name.lower().strip()
|
||||
|
||||
|
||||
@@ -57,10 +57,10 @@ class SettingsMixin:
|
||||
except (OperationalError, ProgrammingError): # pragma: no cover
|
||||
plugin = None
|
||||
|
||||
if not plugin:
|
||||
if not plugin: # pragma: no cover
|
||||
# Cannot find associated plugin model, return
|
||||
logger.error(f"Plugin configuration not found for plugin '{self.slug}'")
|
||||
return # pragma: no cover
|
||||
return
|
||||
|
||||
PluginSetting.set_setting(key, value, user, plugin=plugin)
|
||||
|
||||
@@ -541,7 +541,7 @@ class PanelMixin:
|
||||
|
||||
def get_custom_panels(self, view, request):
|
||||
""" This method *must* be implemented by the plugin class """
|
||||
raise NotImplementedError(f"{__class__} is missing the 'get_custom_panels' method")
|
||||
raise MixinNotImplementedError(f"{__class__} is missing the 'get_custom_panels' method")
|
||||
|
||||
def get_panel_context(self, view, request, context):
|
||||
"""
|
||||
@@ -559,7 +559,7 @@ class PanelMixin:
|
||||
|
||||
try:
|
||||
context['object'] = view.get_object()
|
||||
except AttributeError:
|
||||
except AttributeError: # pragma: no cover
|
||||
pass
|
||||
|
||||
return context
|
||||
|
||||
@@ -8,6 +8,7 @@ from error_report.models import Error
|
||||
|
||||
from InvenTree.helpers import InvenTreeTestCase
|
||||
from plugin import InvenTreePlugin
|
||||
from plugin.base.integration.mixins import PanelMixin
|
||||
from plugin.helpers import MixinNotImplementedError
|
||||
from plugin.mixins import (APICallMixin, AppMixin, NavigationMixin,
|
||||
SettingsMixin, UrlsMixin)
|
||||
@@ -324,7 +325,7 @@ class PanelMixinTests(InvenTreeTestCase):
|
||||
urls = [
|
||||
reverse('part-detail', kwargs={'pk': 1}),
|
||||
reverse('stock-item-detail', kwargs={'pk': 2}),
|
||||
reverse('stock-location-detail', kwargs={'pk': 1}),
|
||||
reverse('stock-location-detail', kwargs={'pk': 2}),
|
||||
]
|
||||
|
||||
plugin.set_setting('ENABLE_HELLO_WORLD', False)
|
||||
@@ -379,3 +380,13 @@ class PanelMixinTests(InvenTreeTestCase):
|
||||
|
||||
# Assert that each request threw an error
|
||||
self.assertEqual(Error.objects.count(), n_errors + len(urls))
|
||||
|
||||
def test_mixin(self):
|
||||
"""Test that ImplementationError is raised"""
|
||||
|
||||
with self.assertRaises(MixinNotImplementedError):
|
||||
class Wrong(PanelMixin, InvenTreePlugin):
|
||||
pass
|
||||
|
||||
plugin = Wrong()
|
||||
plugin.get_custom_panels('abc', 'abc')
|
||||
|
||||
@@ -26,13 +26,13 @@ def print_label(plugin_slug, label_image, label_instance=None, user=None):
|
||||
|
||||
plugin = registry.plugins.get(plugin_slug, None)
|
||||
|
||||
if plugin is None:
|
||||
if plugin is None: # pragma: no cover
|
||||
logger.error(f"Could not find matching plugin for '{plugin_slug}'")
|
||||
return
|
||||
|
||||
try:
|
||||
plugin.print_label(label_image, width=label_instance.width, height=label_instance.height)
|
||||
except Exception as e:
|
||||
except Exception as e: # pragma: no cover
|
||||
# Plugin threw an error - notify the user who attempted to print
|
||||
|
||||
ctx = {
|
||||
|
||||
@@ -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
|
||||
|
||||
from plugin.helpers import MixinImplementationError
|
||||
from plugin.helpers import MixinNotImplementedError
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
@@ -58,7 +58,7 @@ class LocateMixin:
|
||||
if item.in_stock and item.location is not None:
|
||||
self.locate_stock_location(item.location.pk)
|
||||
|
||||
except StockItem.DoesNotExist:
|
||||
except StockItem.DoesNotExist: # pragma: no cover
|
||||
logger.warning("LocateMixin: StockItem pk={item_pk} not found")
|
||||
pass
|
||||
|
||||
@@ -71,4 +71,4 @@ class LocateMixin:
|
||||
|
||||
Note: The default implementation here does nothing!
|
||||
"""
|
||||
raise MixinImplementationError
|
||||
raise MixinNotImplementedError
|
||||
|
||||
@@ -5,7 +5,8 @@ Unit tests for the 'locate' plugin mixin class
|
||||
from django.urls import reverse
|
||||
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
from plugin.registry import registry
|
||||
from plugin import InvenTreePlugin, MixinNotImplementedError, registry
|
||||
from plugin.base.locate.mixins import LocateMixin
|
||||
from stock.models import StockItem, StockLocation
|
||||
|
||||
|
||||
@@ -145,3 +146,17 @@ class LocatePluginTests(InvenTreeAPITestCase):
|
||||
|
||||
# Item metadata should have been altered!
|
||||
self.assertTrue(location.metadata['located'])
|
||||
|
||||
def test_mixin_locate(self):
|
||||
"""Test the sample mixin redirection"""
|
||||
class SamplePlugin(LocateMixin, InvenTreePlugin):
|
||||
pass
|
||||
|
||||
plugin = SamplePlugin()
|
||||
|
||||
# Test that the request is patched through to location
|
||||
with self.assertRaises(MixinNotImplementedError):
|
||||
plugin.locate_stock_item(1)
|
||||
|
||||
# Test that it runs through
|
||||
plugin.locate_stock_item(999)
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
Sample plugin which responds to events
|
||||
"""
|
||||
|
||||
import warnings
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from plugin import InvenTreePlugin
|
||||
from plugin.mixins import EventMixin
|
||||
|
||||
@@ -21,3 +25,7 @@ class EventPluginSample(EventMixin, InvenTreePlugin):
|
||||
print(f"Processing triggered event: '{event}'")
|
||||
print("args:", str(args))
|
||||
print("kwargs:", str(kwargs))
|
||||
|
||||
# Issue warning that we can test for
|
||||
if settings.PLUGIN_TESTING:
|
||||
warnings.warn(f'Event `{event}` triggered')
|
||||
|
||||
@@ -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',
|
||||
'content_template': 'panel_demo/childless.html', # Note that the panel content is rendered using a template file!
|
||||
})
|
||||
except:
|
||||
except: # pragma: no cover
|
||||
pass
|
||||
|
||||
return panels
|
||||
|
||||
@@ -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
|
||||
item.set_metadata('located', True)
|
||||
|
||||
except (ValueError, StockItem.DoesNotExist):
|
||||
except (ValueError, StockItem.DoesNotExist): # pragma: no cover
|
||||
logger.error(f"StockItem ID {item_pk} does not exist!")
|
||||
|
||||
def locate_stock_location(self, location_pk):
|
||||
@@ -53,5 +53,5 @@ class SampleLocatePlugin(LocateMixin, InvenTreePlugin):
|
||||
# Tag metadata
|
||||
location.set_metadata('located', True)
|
||||
|
||||
except (ValueError, StockLocation.DoesNotExist):
|
||||
except (ValueError, StockLocation.DoesNotExist): # pragma: no cover
|
||||
logger.error(f"Location ID {location_pk} does not exist!")
|
||||
|
||||
@@ -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 %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if item.serialized %}
|
||||
<tr>
|
||||
<td><span class='fas fa-hashtag'></span></td>
|
||||
<td>{% trans "Serial Number" %}</td>
|
||||
<td>
|
||||
{{ item.serial }}
|
||||
<div class='btn-group float-right' role='group'>
|
||||
{% if previous %}
|
||||
<a class="btn btn-small btn-outline-secondary" aria-label="{% trans 'previous page' %}" href="{% url request.resolver_match.url_name previous.id %}" title='{% trans "Navigate to previous serial number" %}'>
|
||||
<span class='fas fa-angle-left'></span>
|
||||
<small>{{ previous.serial }}</small>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a class='btn btn-small btn-outline-secondary text-sm' href='#' id='serial-number-search' title='{% trans "Search for serial number" %}'>
|
||||
<span class='fas fa-search'></span>
|
||||
</a>
|
||||
{% if next %}
|
||||
<a class="btn btn-small btn-outline-secondary text-sm" aria-label="{% trans 'next page' %}" href="{% url request.resolver_match.url_name next.id %}" title='{% trans "Navigate to next serial number" %}'>
|
||||
<small>{{ next.serial }}</small>
|
||||
<span class='fas fa-angle-right'></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>{% trans "Quantity" %}</td>
|
||||
<td>{% decimal item.quantity %} {% if item.part.units %}{{ item.part.units }}{% endif %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td><span class='fas fa-info'></span></td>
|
||||
<td>{% trans "Status" %}</td>
|
||||
<td>{% stock_status_label item.status %}</td>
|
||||
</tr>
|
||||
{% if item.expiry_date %}
|
||||
<tr>
|
||||
<td><span class='fas fa-calendar-alt{% if item.is_expired %} icon-red{% endif %}'></span></td>
|
||||
<td>{% trans "Expiry Date" %}</td>
|
||||
<td>
|
||||
{% render_date item.expiry_date %}
|
||||
{% if item.is_expired %}
|
||||
<span title='{% blocktrans %}This StockItem expired on {{ item.expiry_date }}{% endblocktrans %}' class='badge rounded-pill bg-danger badge-right'>{% trans "Expired" %}</span>
|
||||
{% elif item.is_stale %}
|
||||
<span title='{% blocktrans %}This StockItem expires on {{ item.expiry_date }}{% endblocktrans %}' class='badge rounded-pill bg-warning badge-right'>{% trans "Stale" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td><span class='fas fa-calendar-alt'></span></td>
|
||||
<td>{% trans "Last Updated" %}</td>
|
||||
<td>{{ item.updated }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class='fas fa-calendar-alt'></span></td>
|
||||
<td>{% trans "Last Stocktake" %}</td>
|
||||
{% if item.stocktake_date %}
|
||||
<td>{% render_date item.stocktake_date %} <span class='badge badge-right rounded-pill bg-dark'>{{ item.stocktake_user }}</span></td>
|
||||
{% else %}
|
||||
<td><em>{% trans "No stocktake performed" %}</em></td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class='info-messages'>
|
||||
|
||||
{% if item.is_building %}
|
||||
<div class='alert alert-block alert-info'>
|
||||
{% trans "This stock item is in production and cannot be edited." %}<br>
|
||||
{% trans "Edit the stock item from the build view." %}<br>
|
||||
|
||||
{% if item.build %}
|
||||
<a href="{% url 'build-detail' item.build.id %}">
|
||||
<strong>{{ item.build }}</strong>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if item.hasRequiredTests and not item.passedAllRequiredTests %}
|
||||
<div class='alert alert-block alert-danger'>
|
||||
{% trans "This stock item has not passed all required tests" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for allocation in item.sales_order_allocations.all %}
|
||||
<div class='alert alert-block alert-info'>
|
||||
{% object_link 'so-detail' allocation.line.order.id allocation.line.order as link %}
|
||||
{% decimal allocation.quantity as qty %}
|
||||
{% trans "This stock item is allocated to Sales Order" %} {{ link }} {% if qty < item.quantity %}({% trans "Quantity" %}: {{ qty }}){% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% for allocation in item.allocations.all %}
|
||||
<div class='alert alert-block alert-info'>
|
||||
{% object_link 'build-detail' allocation.build.id allocation.build as link %}
|
||||
{% decimal allocation.quantity as qty %}
|
||||
{% trans "This stock item is allocated to Build Order" %} {{ link }} {% if qty < item.quantity %}({% trans "Quantity" %}: {{ qty }}){% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% if item.serialized %}
|
||||
<div class='alert alert-block alert-warning'>
|
||||
{% trans "This stock item is serialized - it has a unique serial number and the quantity cannot be adjusted." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock details %}
|
||||
|
||||
{% block details_right %}
|
||||
<table class="table table-striped table-condensed">
|
||||
<col width='25'>
|
||||
|
||||
{% if item.customer %}
|
||||
<tr>
|
||||
<td><span class='fas fa-user-tie'></span></td>
|
||||
<td>{% trans "Customer" %}</td>
|
||||
<td><a href="{% url 'company-detail' item.customer.id %}?display=assigned-stock">{{ item.customer.name }}</a></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if item.belongs_to %}
|
||||
<tr>
|
||||
<td><span class='fas fa-box'></span></td>
|
||||
<td>
|
||||
{% trans "Installed In" %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{% url 'stock-item-detail' item.belongs_to.id %}">{{ item.belongs_to }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% elif item.sales_order %}
|
||||
<tr>
|
||||
<td><span class='fas fa-user-tie'></span></td>
|
||||
<td>{% trans "Sales Order" %}</td>
|
||||
<td><a href="{% url 'so-detail' item.sales_order.id %}">{{ item.sales_order.reference }}</a> - <a href="{% url 'company-detail' item.sales_order.customer.id %}">{{ item.sales_order.customer.name }}</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td><span class='fas fa-map-marker-alt'></span></td>
|
||||
<td>{% trans "Location" %}</td>
|
||||
{% if item.location %}
|
||||
<td><a href="{% url 'stock-location-detail' item.location.id %}">{{ item.location.name }}</a></td>
|
||||
{% else %}
|
||||
<td><em>{% trans "No location set" %}</em></td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
{% if item.uid %}
|
||||
<tr>
|
||||
<td><span class='fas fa-barcode'></span></td>
|
||||
@@ -322,13 +171,6 @@
|
||||
<td>{{ item.batch }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if item.packaging %}
|
||||
<tr>
|
||||
<td><span class='fas fa-cube'></span></td>
|
||||
<td>{% trans "Packaging" %}</td>
|
||||
<td>{{ item.packaging }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if item.build %}
|
||||
<tr>
|
||||
<td><span class='fas fa-tools'></span></td>
|
||||
@@ -397,18 +239,11 @@
|
||||
<td><a href="{% url 'supplier-part-detail' item.supplier_part.id %}">{{ item.supplier_part.SKU }}</a></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if item.hasRequiredTests %}
|
||||
{% if item.packaging %}
|
||||
<tr>
|
||||
<td><span class='fas fa-vial'></span></td>
|
||||
<td>{% trans "Tests" %}</td>
|
||||
<td>
|
||||
{{ item.requiredTestStatus.passed }} / {{ item.requiredTestStatus.total }}
|
||||
{% if item.passedAllRequiredTests %}
|
||||
<span class='fas fa-check-circle float-right icon-green'></span>
|
||||
{% else %}
|
||||
<span class='fas fa-times-circle float-right icon-red'></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><span class='fas fa-cube'></span></td>
|
||||
<td>{% trans "Packaging" %}</td>
|
||||
<td>{{ item.packaging }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if ownership_enabled and item_owner %}
|
||||
@@ -425,6 +260,199 @@
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
</table>
|
||||
|
||||
<div class='info-messages'>
|
||||
|
||||
{% if item.is_building %}
|
||||
<div class='alert alert-block alert-info'>
|
||||
{% trans "This stock item is in production and cannot be edited." %}<br>
|
||||
{% trans "Edit the stock item from the build view." %}<br>
|
||||
|
||||
{% if item.build %}
|
||||
<a href="{% url 'build-detail' item.build.id %}">
|
||||
<strong>{{ item.build }}</strong>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if item.hasRequiredTests and not item.passedAllRequiredTests %}
|
||||
<div class='alert alert-block alert-danger'>
|
||||
{% trans "This stock item has not passed all required tests" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for allocation in item.sales_order_allocations.all %}
|
||||
<div class='alert alert-block alert-info'>
|
||||
{% object_link 'so-detail' allocation.line.order.id allocation.line.order as link %}
|
||||
{% decimal allocation.quantity as qty %}
|
||||
{% trans "This stock item is allocated to Sales Order" %} {{ link }} {% if qty < item.quantity %}({% trans "Quantity" %}: {{ qty }}){% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% for allocation in item.allocations.all %}
|
||||
<div class='alert alert-block alert-info'>
|
||||
{% object_link 'build-detail' allocation.build.id allocation.build as link %}
|
||||
{% decimal allocation.quantity as qty %}
|
||||
{% trans "This stock item is allocated to Build Order" %} {{ link }} {% if qty < item.quantity %}({% trans "Quantity" %}: {{ qty }}){% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% if item.serialized %}
|
||||
<div class='alert alert-block alert-warning'>
|
||||
{% trans "This stock item is serialized - it has a unique serial number and the quantity cannot be adjusted." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock details %}
|
||||
|
||||
{% block details_right %}
|
||||
<table class="table table-striped table-condensed">
|
||||
<col width='25'>
|
||||
|
||||
<tr>
|
||||
{% if item.serialized %}
|
||||
<td>
|
||||
<h5><span class='fas fa-hashtag'></span></h5>
|
||||
</td>
|
||||
<td>
|
||||
<h5>{% trans "Serial Number" %}</h5>
|
||||
</td>
|
||||
<td><h5>
|
||||
{{ item.serial }}
|
||||
<div class='btn-group float-right' role='group'>
|
||||
{% if previous %}
|
||||
<a class="btn btn-small btn-outline-secondary" aria-label="{% trans 'previous page' %}" href="{% url request.resolver_match.url_name previous.id %}" title='{% trans "Navigate to previous serial number" %}'>
|
||||
<span class='fas fa-angle-left'></span>
|
||||
<small>{{ previous.serial }}</small>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a class='btn btn-small btn-outline-secondary text-sm' href='#' id='serial-number-search' title='{% trans "Search for serial number" %}'>
|
||||
<span class='fas fa-search'></span>
|
||||
</a>
|
||||
{% if next %}
|
||||
<a class="btn btn-small btn-outline-secondary text-sm" aria-label="{% trans 'next page' %}" href="{% url request.resolver_match.url_name next.id %}" title='{% trans "Navigate to next serial number" %}'>
|
||||
<small>{{ next.serial }}</small>
|
||||
<span class='fas fa-angle-right'></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</h5>
|
||||
</td>
|
||||
{% else %}
|
||||
<td>
|
||||
<h5><div class='fas fa-boxes'></div></h5>
|
||||
</td>
|
||||
<td>
|
||||
<h5>{% trans "Available Quantity" %}</h5>
|
||||
</td>
|
||||
<td>
|
||||
<h5>{% if item.quantity != available %}{% decimal available %} / {% endif %}{% decimal item.quantity %} {% if item.part.units %}{{ item.part.units }}{% endif %}</h5>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% if item.belongs_to %}
|
||||
<tr>
|
||||
<td><span class='fas fa-box'></span></td>
|
||||
<td>
|
||||
{% trans "Installed In" %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{% url 'stock-item-detail' item.belongs_to.id %}">{{ item.belongs_to }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% elif item.sales_order %}
|
||||
<tr>
|
||||
<td><span class='fas fa-user-tie'></span></td>
|
||||
<td>{% trans "Sales Order" %}</td>
|
||||
<td><a href="{% url 'so-detail' item.sales_order.id %}">{{ item.sales_order.reference }}</a> - <a href="{% url 'company-detail' item.sales_order.customer.id %}">{{ item.sales_order.customer.name }}</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% if allocated_to_sales_orders %}
|
||||
<tr>
|
||||
<td><span class='fas fa-truck'></span></td>
|
||||
<td>{% trans "Allocated to Sales Orders" %}</td>
|
||||
<td>{% decimal allocated_to_sales_orders %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if allocated_to_build_orders %}
|
||||
<tr>
|
||||
<td><span class='fas fa-tools'></span></td>
|
||||
<td>{% trans "Allocated to Build Orders" %}</td>
|
||||
<td>{% decimal allocated_to_build_orders %}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td><span class='fas fa-map-marker-alt'></span></td>
|
||||
<td>{% trans "Location" %}</td>
|
||||
{% if item.location %}
|
||||
<td><a href="{% url 'stock-location-detail' item.location.id %}">{{ item.location.name }}</a></td>
|
||||
{% elif not item.customer %}
|
||||
<td><em>{% trans "No location set" %}</em></td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if item.customer %}
|
||||
<tr>
|
||||
<td><span class='fas fa-user-tie'></span></td>
|
||||
<td>{% trans "Customer" %}</td>
|
||||
<td><a href="{% url 'company-detail' item.customer.id %}?display=assigned-stock">{{ item.customer.name }}</a></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
{% if item.hasRequiredTests %}
|
||||
<tr>
|
||||
<td><span class='fas fa-vial'></span></td>
|
||||
<td>{% trans "Tests" %}</td>
|
||||
<td>
|
||||
{{ item.requiredTestStatus.passed }} / {{ item.requiredTestStatus.total }}
|
||||
{% if item.passedAllRequiredTests %}
|
||||
<span class='fas fa-check-circle float-right icon-green'></span>
|
||||
{% else %}
|
||||
<span class='fas fa-times-circle float-right icon-red'></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
<tr>
|
||||
<td><span class='fas fa-info'></span></td>
|
||||
<td>{% trans "Status" %}</td>
|
||||
<td>{% stock_status_label item.status %}</td>
|
||||
</tr>
|
||||
{% if item.expiry_date %}
|
||||
<tr>
|
||||
<td><span class='fas fa-calendar-alt{% if item.is_expired %} icon-red{% endif %}'></span></td>
|
||||
<td>{% trans "Expiry Date" %}</td>
|
||||
<td>
|
||||
{% render_date item.expiry_date %}
|
||||
{% if item.is_expired %}
|
||||
<span title='{% blocktrans %}This StockItem expired on {{ item.expiry_date }}{% endblocktrans %}' class='badge rounded-pill bg-danger badge-right'>{% trans "Expired" %}</span>
|
||||
{% elif item.is_stale %}
|
||||
<span title='{% blocktrans %}This StockItem expires on {{ item.expiry_date }}{% endblocktrans %}' class='badge rounded-pill bg-warning badge-right'>{% trans "Stale" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td><span class='fas fa-calendar-alt'></span></td>
|
||||
<td>{% trans "Last Updated" %}</td>
|
||||
<td>{{ item.updated }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class='fas fa-calendar-alt'></span></td>
|
||||
<td>{% trans "Last Stocktake" %}</td>
|
||||
{% if item.stocktake_date %}
|
||||
<td>{% render_date item.stocktake_date %} <span class='badge badge-right rounded-pill bg-dark'>{{ item.stocktake_user }}</span></td>
|
||||
{% else %}
|
||||
<td><em>{% trans "No stocktake performed" %}</em></td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
|
||||
|
||||
</table>
|
||||
{% endblock details_right %}
|
||||
|
||||
|
||||
@@ -94,6 +94,12 @@ class StockItemDetail(InvenTreeRoleMixin, InvenTreePluginViewMixin, DetailView):
|
||||
data['item_owner'] = self.object.get_item_owner()
|
||||
data['user_owns_item'] = self.object.check_ownership(self.request.user)
|
||||
|
||||
# Allocation information
|
||||
data['allocated_to_sales_orders'] = self.object.sales_order_allocation_count()
|
||||
data['allocated_to_build_orders'] = self.object.build_allocation_count()
|
||||
data['allocated_to_orders'] = data['allocated_to_sales_orders'] + data['allocated_to_build_orders']
|
||||
data['available'] = max(0, self.object.quantity - data['allocated_to_orders'])
|
||||
|
||||
return data
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
|
||||
@@ -129,7 +129,12 @@ function completeShipment(shipment_id, options={}) {
|
||||
method: 'POST',
|
||||
title: `{% trans "Complete Shipment" %} ${shipment.reference}`,
|
||||
fields: {
|
||||
tracking_number: {},
|
||||
tracking_number: {
|
||||
value: shipment.tracking_number,
|
||||
},
|
||||
shipment_date: {
|
||||
value: moment().format('YYYY-MM-DD'),
|
||||
}
|
||||
},
|
||||
preFormContent: html,
|
||||
confirm: true,
|
||||
|
||||
@@ -1698,13 +1698,22 @@ function loadStockTable(table, options) {
|
||||
sortable: true,
|
||||
formatter: function(value, row) {
|
||||
|
||||
var val = parseFloat(value);
|
||||
var val = '';
|
||||
|
||||
var available = Math.max(0, (row.quantity || 0) - (row.allocated || 0));
|
||||
|
||||
// If there is a single unit with a serial number, use the serial number
|
||||
if (row.serial && row.quantity == 1) {
|
||||
// If there is a single unit with a serial number, use the serial number
|
||||
val = '# ' + row.serial;
|
||||
} else if (row.quantity != available) {
|
||||
// Some quantity is available, show available *and* quantity
|
||||
var ava = +parseFloat(available).toFixed(5);
|
||||
var tot = +parseFloat(row.quantity).toFixed(5);
|
||||
|
||||
val = `${ava} / ${tot}`;
|
||||
} else {
|
||||
val = +val.toFixed(5);
|
||||
// Format floating point numbers with this one weird trick
|
||||
val = +parseFloat(value).toFixed(5);
|
||||
}
|
||||
|
||||
var html = renderLink(val, `/stock/item/${row.pk}/`);
|
||||
@@ -1719,16 +1728,7 @@ function loadStockTable(table, options) {
|
||||
} else if (row.customer) {
|
||||
// StockItem has been assigned to a customer
|
||||
html += makeIconBadge('fa-user', '{% trans "Stock item assigned to customer" %}');
|
||||
}
|
||||
|
||||
if (row.expired) {
|
||||
html += makeIconBadge('fa-calendar-times icon-red', '{% trans "Stock item has expired" %}');
|
||||
} else if (row.stale) {
|
||||
html += makeIconBadge('fa-stopwatch', '{% trans "Stock item will expire soon" %}');
|
||||
}
|
||||
|
||||
if (row.allocated) {
|
||||
|
||||
} else if (row.allocated) {
|
||||
if (row.serial != null && row.quantity == 1) {
|
||||
html += makeIconBadge('fa-bookmark icon-yellow', '{% trans "Serialized stock item has been allocated" %}');
|
||||
} else if (row.allocated >= row.quantity) {
|
||||
@@ -1736,10 +1736,14 @@ function loadStockTable(table, options) {
|
||||
} else {
|
||||
html += makeIconBadge('fa-bookmark', '{% trans "Stock item has been partially allocated" %}');
|
||||
}
|
||||
} else if (row.belongs_to) {
|
||||
html += makeIconBadge('fa-box', '{% trans "Stock item has been installed in another item" %}');
|
||||
}
|
||||
|
||||
if (row.belongs_to) {
|
||||
html += makeIconBadge('fa-box', '{% trans "Stock item has been installed in another item" %}');
|
||||
if (row.expired) {
|
||||
html += makeIconBadge('fa-calendar-times icon-red', '{% trans "Stock item has expired" %}');
|
||||
} else if (row.stale) {
|
||||
html += makeIconBadge('fa-stopwatch', '{% trans "Stock item will expire soon" %}');
|
||||
}
|
||||
|
||||
// Special stock status codes
|
||||
|
||||
@@ -180,11 +180,23 @@ class OwnerModelTest(InvenTreeTestCase):
|
||||
group_as_owner = Owner.get_owner(self.group)
|
||||
self.assertEqual(type(group_as_owner), Owner)
|
||||
|
||||
# Check name
|
||||
self.assertEqual(str(user_as_owner), 'testuser (user)')
|
||||
|
||||
# Get related owners (user + group)
|
||||
related_owners = group_as_owner.get_related_owners(include_group=True)
|
||||
self.assertTrue(user_as_owner in related_owners)
|
||||
self.assertTrue(group_as_owner in related_owners)
|
||||
|
||||
# Get related owners (only user)
|
||||
related_owners = group_as_owner.get_related_owners(include_group=False)
|
||||
self.assertTrue(user_as_owner in related_owners)
|
||||
self.assertFalse(group_as_owner in related_owners)
|
||||
|
||||
# Get related owners on user
|
||||
related_owners = user_as_owner.get_related_owners()
|
||||
self.assertEqual(related_owners, [user_as_owner])
|
||||
|
||||
# Check owner matching
|
||||
owners = Owner.get_owners_matching_user(self.user)
|
||||
self.assertEqual(owners, [user_as_owner, group_as_owner])
|
||||
|
||||
Reference in New Issue
Block a user