mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
commit
4c65b4d5ba
@ -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')
|
||||
|
@ -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()
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1205,8 +1205,9 @@ class SalesOrderShipment(models.Model):
|
||||
def is_complete(self):
|
||||
return self.shipment_date is not None
|
||||
|
||||
def check_can_complete(self):
|
||||
def check_can_complete(self, raise_error=True):
|
||||
|
||||
try:
|
||||
if self.shipment_date:
|
||||
# Shipment has already been sent!
|
||||
raise ValidationError(_("Shipment has already been sent"))
|
||||
@ -1214,6 +1215,14 @@ class SalesOrderShipment(models.Model):
|
||||
if self.allocations.count() == 0:
|
||||
raise ValidationError(_("Shipment has no allocated stock items"))
|
||||
|
||||
except ValidationError as e:
|
||||
if raise_error:
|
||||
raise e
|
||||
else:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@transaction.atomic
|
||||
def complete_shipment(self, user, **kwargs):
|
||||
"""
|
||||
@ -1235,7 +1244,7 @@ class SalesOrderShipment(models.Model):
|
||||
allocation.complete_allocation(user)
|
||||
|
||||
# Update the "shipment" date
|
||||
self.shipment_date = datetime.now()
|
||||
self.shipment_date = kwargs.get('shipment_date', datetime.now())
|
||||
self.shipped_by = user
|
||||
|
||||
# Was a tracking number provided?
|
||||
|
@ -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 = {
|
||||
|
209
InvenTree/plugin/base/label/test_label_mixin.py
Normal file
209
InvenTree/plugin/base/label/test_label_mixin.py
Normal file
@ -0,0 +1,209 @@
|
||||
"""Unit tests for the label printing mixin"""
|
||||
|
||||
from django.apps import apps
|
||||
from django.urls import reverse
|
||||
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
from label.models import PartLabel, StockItemLabel, StockLocationLabel
|
||||
from part.models import Part
|
||||
from plugin.base.label.mixins import LabelPrintingMixin
|
||||
from plugin.helpers import MixinNotImplementedError
|
||||
from plugin.plugin import InvenTreePlugin
|
||||
from plugin.registry import registry
|
||||
from stock.models import StockItem, StockLocation
|
||||
|
||||
|
||||
class LabelMixinTests(InvenTreeAPITestCase):
|
||||
"""Test that the Label mixin operates correctly"""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
'part',
|
||||
'location',
|
||||
'stock',
|
||||
]
|
||||
|
||||
roles = 'all'
|
||||
|
||||
def do_activate_plugin(self):
|
||||
"""Activate the 'samplelabel' plugin"""
|
||||
|
||||
config = registry.get_plugin('samplelabel').plugin_config()
|
||||
config.active = True
|
||||
config.save()
|
||||
|
||||
def do_url(self, parts, plugin_ref, label, url_name: str = 'api-part-label-print', url_single: str = 'part', invalid: bool = False):
|
||||
"""Generate an URL to print a label"""
|
||||
# Construct URL
|
||||
kwargs = {}
|
||||
if label:
|
||||
kwargs["pk"] = label.pk
|
||||
|
||||
url = reverse(url_name, kwargs=kwargs)
|
||||
|
||||
# Append part filters
|
||||
if not parts:
|
||||
pass
|
||||
elif len(parts) == 1:
|
||||
url += f'?{url_single}={parts[0].pk}'
|
||||
elif len(parts) > 1:
|
||||
url += '?' + '&'.join([f'{url_single}s={item.pk}' for item in parts])
|
||||
|
||||
# Append an invalid item
|
||||
if invalid:
|
||||
url += f'&{url_single}{"s" if len(parts) > 1 else ""}=abc'
|
||||
|
||||
# Append plugin reference
|
||||
if plugin_ref:
|
||||
url += f'&plugin={plugin_ref}'
|
||||
|
||||
return url
|
||||
|
||||
def test_wrong_implementation(self):
|
||||
"""Test that a wrong implementation raises an error"""
|
||||
|
||||
class WrongPlugin(LabelPrintingMixin, InvenTreePlugin):
|
||||
pass
|
||||
|
||||
with self.assertRaises(MixinNotImplementedError):
|
||||
plugin = WrongPlugin()
|
||||
plugin.print_label('test')
|
||||
|
||||
def test_installed(self):
|
||||
"""Test that the sample printing plugin is installed"""
|
||||
|
||||
# Get all label plugins
|
||||
plugins = registry.with_mixin('labels')
|
||||
self.assertEqual(len(plugins), 1)
|
||||
|
||||
# But, it is not 'active'
|
||||
plugins = registry.with_mixin('labels', active=True)
|
||||
self.assertEqual(len(plugins), 0)
|
||||
|
||||
def test_api(self):
|
||||
"""Test that we can filter the API endpoint by mixin"""
|
||||
|
||||
url = reverse('api-plugin-list')
|
||||
|
||||
# Try POST (disallowed)
|
||||
response = self.client.post(url, {})
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
response = self.client.get(
|
||||
url,
|
||||
{
|
||||
'mixin': 'labels',
|
||||
'active': True,
|
||||
}
|
||||
)
|
||||
|
||||
# No results matching this query!
|
||||
self.assertEqual(len(response.data), 0)
|
||||
|
||||
# What about inactive?
|
||||
response = self.client.get(
|
||||
url,
|
||||
{
|
||||
'mixin': 'labels',
|
||||
'active': False,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 0)
|
||||
|
||||
self.do_activate_plugin()
|
||||
# Should be available via the API now
|
||||
response = self.client.get(
|
||||
url,
|
||||
{
|
||||
'mixin': 'labels',
|
||||
'active': True,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(len(response.data), 1)
|
||||
data = response.data[0]
|
||||
self.assertEqual(data['key'], 'samplelabel')
|
||||
|
||||
def test_printing_process(self):
|
||||
"""Test that a label can be printed"""
|
||||
|
||||
# Ensure the labels were created
|
||||
apps.get_app_config('label').create_labels()
|
||||
|
||||
# Lookup references
|
||||
part = Part.objects.first()
|
||||
plugin_ref = 'samplelabel'
|
||||
label = PartLabel.objects.first()
|
||||
|
||||
url = self.do_url([part], plugin_ref, label)
|
||||
|
||||
# Non-exsisting plugin
|
||||
response = self.get(f'{url}123', expected_code=404)
|
||||
self.assertIn(f'Plugin \'{plugin_ref}123\' not found', str(response.content, 'utf8'))
|
||||
|
||||
# Inactive plugin
|
||||
response = self.get(url, expected_code=400)
|
||||
self.assertIn(f'Plugin \'{plugin_ref}\' is not enabled', str(response.content, 'utf8'))
|
||||
|
||||
# Active plugin
|
||||
self.do_activate_plugin()
|
||||
|
||||
# Print one part
|
||||
self.get(url, expected_code=200)
|
||||
|
||||
# Print multiple parts
|
||||
self.get(self.do_url(Part.objects.all()[:2], plugin_ref, label), expected_code=200)
|
||||
|
||||
# Print multiple parts without a plugin
|
||||
self.get(self.do_url(Part.objects.all()[:2], None, label), expected_code=200)
|
||||
|
||||
# Print multiple parts without a plugin in debug mode
|
||||
InvenTreeSetting.set_setting('REPORT_DEBUG_MODE', True, None)
|
||||
response = self.get(self.do_url(Part.objects.all()[:2], None, label), expected_code=200)
|
||||
self.assertIn('@page', str(response.content))
|
||||
|
||||
# Print no part
|
||||
self.get(self.do_url(None, plugin_ref, label), expected_code=400)
|
||||
|
||||
def test_printing_endpoints(self):
|
||||
"""Cover the endpoints not covered by `test_printing_process`"""
|
||||
plugin_ref = 'samplelabel'
|
||||
|
||||
# Activate the label components
|
||||
apps.get_app_config('label').create_labels()
|
||||
self.do_activate_plugin()
|
||||
|
||||
def run_print_test(label, qs, url_name, url_single):
|
||||
"""Run tests on single and multiple page printing
|
||||
|
||||
Args:
|
||||
label (_type_): class of the label
|
||||
qs (_type_): class of the base queryset
|
||||
url_name (_type_): url for endpoints
|
||||
url_single (_type_): item lookup reference
|
||||
"""
|
||||
label = label.objects.first()
|
||||
qs = qs.objects.all()
|
||||
|
||||
# List endpoint
|
||||
self.get(self.do_url(None, None, None, f'{url_name}-list', url_single), expected_code=200)
|
||||
|
||||
# List endpoint with filter
|
||||
self.get(self.do_url(qs[:2], None, None, f'{url_name}-list', url_single, invalid=True), expected_code=200)
|
||||
|
||||
# Single page printing
|
||||
self.get(self.do_url(qs[:1], plugin_ref, label, f'{url_name}-print', url_single), expected_code=200)
|
||||
|
||||
# Multi page printing
|
||||
self.get(self.do_url(qs[:2], plugin_ref, label, f'{url_name}-print', url_single), expected_code=200)
|
||||
|
||||
# Test StockItemLabels
|
||||
run_print_test(StockItemLabel, StockItem, 'api-stockitem-label', 'item')
|
||||
|
||||
# Test StockLocationLabels
|
||||
run_print_test(StockLocationLabel, StockLocation, 'api-stocklocation-label', 'location')
|
||||
|
||||
# Test PartLabels
|
||||
run_print_test(PartLabel, Part, 'api-part-label', 'part')
|
@ -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')
|
||||
|
40
InvenTree/plugin/samples/event/test_event_sample.py
Normal file
40
InvenTree/plugin/samples/event/test_event_sample.py
Normal file
@ -0,0 +1,40 @@
|
||||
"""Unit tests for event_sample sample plugins"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
|
||||
from plugin import InvenTreePlugin, registry
|
||||
from plugin.base.event.events import trigger_event
|
||||
from plugin.helpers import MixinNotImplementedError
|
||||
from plugin.mixins import EventMixin
|
||||
|
||||
|
||||
class EventPluginSampleTests(TestCase):
|
||||
"""Tests for EventPluginSample"""
|
||||
|
||||
def test_run_event(self):
|
||||
"""Check if the event is issued"""
|
||||
# Activate plugin
|
||||
config = registry.get_plugin('sampleevent').plugin_config()
|
||||
config.active = True
|
||||
config.save()
|
||||
|
||||
# Enable event testing
|
||||
settings.PLUGIN_TESTING_EVENTS = True
|
||||
# Check that an event is issued
|
||||
with self.assertWarns(Warning) as cm:
|
||||
trigger_event('test.event')
|
||||
self.assertEqual(cm.warning.args[0], 'Event `test.event` triggered')
|
||||
|
||||
# Disable again
|
||||
settings.PLUGIN_TESTING_EVENTS = False
|
||||
|
||||
def test_mixin(self):
|
||||
"""Test that MixinNotImplementedError is raised"""
|
||||
|
||||
with self.assertRaises(MixinNotImplementedError):
|
||||
class Wrong(EventMixin, InvenTreePlugin):
|
||||
pass
|
||||
|
||||
plugin = Wrong()
|
||||
plugin.process_event('abc')
|
@ -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
|
||||
|
18
InvenTree/plugin/samples/integration/label_sample.py
Normal file
18
InvenTree/plugin/samples/integration/label_sample.py
Normal file
@ -0,0 +1,18 @@
|
||||
|
||||
from plugin import InvenTreePlugin
|
||||
from plugin.mixins import LabelPrintingMixin
|
||||
|
||||
|
||||
class SampleLabelPrinter(LabelPrintingMixin, InvenTreePlugin):
|
||||
"""
|
||||
Sample plugin which provides a 'fake' label printer endpoint
|
||||
"""
|
||||
|
||||
NAME = "Label Printer"
|
||||
SLUG = "samplelabel"
|
||||
TITLE = "Sample Label Printer"
|
||||
DESCRIPTION = "A sample plugin which provides a (fake) label printer interface"
|
||||
VERSION = "0.1"
|
||||
|
||||
def print_label(self, label, **kwargs):
|
||||
print("OK PRINTING")
|
@ -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!")
|
||||
|
60
InvenTree/plugin/samples/locate/test_locate_sample.py
Normal file
60
InvenTree/plugin/samples/locate/test_locate_sample.py
Normal file
@ -0,0 +1,60 @@
|
||||
"""Unit tests for locate_sample sample plugins"""
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
||||
from plugin import InvenTreePlugin, registry
|
||||
from plugin.helpers import MixinNotImplementedError
|
||||
from plugin.mixins import LocateMixin
|
||||
|
||||
|
||||
class SampleLocatePlugintests(InvenTreeAPITestCase):
|
||||
"""Tests for SampleLocatePlugin"""
|
||||
|
||||
fixtures = [
|
||||
'location',
|
||||
'category',
|
||||
'part',
|
||||
'stock'
|
||||
]
|
||||
|
||||
def test_run_locator(self):
|
||||
"""Check if the event is issued"""
|
||||
# Activate plugin
|
||||
config = registry.get_plugin('samplelocate').plugin_config()
|
||||
config.active = True
|
||||
config.save()
|
||||
|
||||
# Test APIs
|
||||
url = reverse('api-locate-plugin')
|
||||
|
||||
# No plugin
|
||||
self.post(url, {}, expected_code=400)
|
||||
|
||||
# Wrong plugin
|
||||
self.post(url, {'plugin': 'sampleevent'}, expected_code=400)
|
||||
|
||||
# Right plugin - no search item
|
||||
self.post(url, {'plugin': 'samplelocate'}, expected_code=400)
|
||||
|
||||
# Right plugin - wrong reference
|
||||
self.post(url, {'plugin': 'samplelocate', 'item': 999}, expected_code=404)
|
||||
|
||||
# Right plugin - right reference
|
||||
self.post(url, {'plugin': 'samplelocate', 'item': 1}, expected_code=200)
|
||||
|
||||
# Right plugin - wrong reference
|
||||
self.post(url, {'plugin': 'samplelocate', 'location': 999}, expected_code=404)
|
||||
|
||||
# Right plugin - right reference
|
||||
self.post(url, {'plugin': 'samplelocate', 'location': 1}, expected_code=200)
|
||||
|
||||
def test_mixin(self):
|
||||
"""Test that MixinNotImplementedError is raised"""
|
||||
|
||||
with self.assertRaises(MixinNotImplementedError):
|
||||
class Wrong(LocateMixin, InvenTreePlugin):
|
||||
pass
|
||||
|
||||
plugin = Wrong()
|
||||
plugin.locate_stock_location(1)
|
@ -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])
|
||||
|
Loading…
Reference in New Issue
Block a user