\ No newline at end of file
diff --git a/InvenTree/stock/test_api.py b/InvenTree/stock/test_api.py
index fe49547cee..cd598e8538 100644
--- a/InvenTree/stock/test_api.py
+++ b/InvenTree/stock/test_api.py
@@ -6,11 +6,17 @@ from django.contrib.auth import get_user_model
from .models import StockLocation
-class StockLocationTest(APITestCase):
- """
- Series of API tests for the StockLocation API
- """
- list_url = reverse('api-location-list')
+class StockAPITestCase(APITestCase):
+
+ fixtures = [
+ 'category',
+ 'part',
+ 'company',
+ 'location',
+ 'supplier_part',
+ 'stock',
+ 'stock_tests',
+ ]
def setUp(self):
# Create a user for auth
@@ -18,6 +24,21 @@ class StockLocationTest(APITestCase):
User.objects.create_user('testuser', 'test@testing.com', 'password')
self.client.login(username='testuser', password='password')
+ def doPost(self, url, data={}):
+ response = self.client.post(url, data=data, format='json')
+
+ return response
+
+
+class StockLocationTest(StockAPITestCase):
+ """
+ Series of API tests for the StockLocation API
+ """
+ list_url = reverse('api-location-list')
+
+ def setUp(self):
+ super().setUp()
+
# Add some stock locations
StockLocation.objects.create(name='top', description='top category')
@@ -38,7 +59,7 @@ class StockLocationTest(APITestCase):
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
-class StockItemTest(APITestCase):
+class StockItemTest(StockAPITestCase):
"""
Series of API tests for the StockItem API
"""
@@ -49,11 +70,7 @@ class StockItemTest(APITestCase):
return reverse('api-stock-detail', kwargs={'pk': pk})
def setUp(self):
- # Create a user for auth
- User = get_user_model()
- User.objects.create_user('testuser', 'test@testing.com', 'password')
- self.client.login(username='testuser', password='password')
-
+ super().setUp()
# Create some stock locations
top = StockLocation.objects.create(name='A', description='top')
@@ -65,30 +82,11 @@ class StockItemTest(APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
-class StocktakeTest(APITestCase):
+class StocktakeTest(StockAPITestCase):
"""
Series of tests for the Stocktake API
"""
- fixtures = [
- 'category',
- 'part',
- 'company',
- 'location',
- 'supplier_part',
- 'stock',
- ]
-
- def setUp(self):
- User = get_user_model()
- User.objects.create_user('testuser', 'test@testing.com', 'password')
- self.client.login(username='testuser', password='password')
-
- def doPost(self, url, data={}):
- response = self.client.post(url, data=data, format='json')
-
- return response
-
def test_action(self):
"""
Test each stocktake action endpoint,
@@ -179,3 +177,82 @@ class StocktakeTest(APITestCase):
response = self.doPost(url, data)
self.assertContains(response, 'Valid location must be specified', status_code=status.HTTP_400_BAD_REQUEST)
+
+
+class StockTestResultTest(StockAPITestCase):
+
+ def get_url(self):
+ return reverse('api-stock-test-result-list')
+
+ def test_list(self):
+
+ url = self.get_url()
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertGreaterEqual(len(response.data), 4)
+
+ response = self.client.get(url, data={'stock_item': 105})
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertGreaterEqual(len(response.data), 4)
+
+ def test_post_fail(self):
+ # Attempt to post a new test result without specifying required data
+
+ url = self.get_url()
+
+ response = self.client.post(
+ url,
+ data={
+ 'test': 'A test',
+ 'result': True,
+ },
+ format='json'
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+ # This one should pass!
+ response = self.client.post(
+ url,
+ data={
+ 'test': 'A test',
+ 'stock_item': 105,
+ 'result': True,
+ },
+ format='json'
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+ def test_post(self):
+ # Test creation of a new test result
+
+ url = self.get_url()
+
+ response = self.client.get(url)
+ n = len(response.data)
+
+ data = {
+ 'stock_item': 105,
+ 'test': 'Checked Steam Valve',
+ 'result': False,
+ 'value': '150kPa',
+ 'notes': 'I guess there was just too much pressure?',
+ }
+
+ response = self.client.post(url, data, format='json')
+
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+ response = self.client.get(url)
+ self.assertEqual(len(response.data), n + 1)
+
+ # And read out again
+ response = self.client.get(url, data={'test': 'Checked Steam Valve'})
+
+ self.assertEqual(len(response.data), 1)
+
+ test = response.data[0]
+ self.assertEqual(test['value'], '150kPa')
+ self.assertEqual(test['user'], 1)
diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py
index a866bdb880..8fce455a97 100644
--- a/InvenTree/stock/tests.py
+++ b/InvenTree/stock/tests.py
@@ -17,6 +17,7 @@ class StockTest(TestCase):
'part',
'location',
'stock',
+ 'stock_tests',
]
def setUp(self):
@@ -38,6 +39,10 @@ class StockTest(TestCase):
self.user = User.objects.get(username='username')
+ # Ensure the MPTT objects are correctly rebuild
+ Part.objects.rebuild()
+ StockItem.objects.rebuild()
+
def test_loc_count(self):
self.assertEqual(StockLocation.objects.count(), 7)
@@ -91,17 +96,20 @@ class StockTest(TestCase):
self.assertFalse(self.drawer2.has_items())
# Drawer 3 should have three stock items
- self.assertEqual(self.drawer3.stock_items.count(), 3)
- self.assertEqual(self.drawer3.item_count, 3)
+ self.assertEqual(self.drawer3.stock_items.count(), 16)
+ self.assertEqual(self.drawer3.item_count, 16)
def test_stock_count(self):
part = Part.objects.get(pk=1)
+ entries = part.stock_entries()
- # There should be 5000 screws in stock
+ self.assertEqual(entries.count(), 2)
+
+ # There should be 9000 screws in stock
self.assertEqual(part.total_stock, 9000)
# There should be 18 widgets in stock
- self.assertEqual(StockItem.objects.filter(part=25).aggregate(Sum('quantity'))['quantity__sum'], 18)
+ self.assertEqual(StockItem.objects.filter(part=25).aggregate(Sum('quantity'))['quantity__sum'], 19)
def test_delete_location(self):
@@ -161,12 +169,12 @@ class StockTest(TestCase):
self.assertEqual(w1.quantity, 4)
# There should also be a new object still in drawer3
- self.assertEqual(StockItem.objects.filter(part=25).count(), 4)
+ self.assertEqual(StockItem.objects.filter(part=25).count(), 5)
widget = StockItem.objects.get(location=self.drawer3.id, part=25, quantity=4)
# Try to move negative units
self.assertFalse(widget.move(self.bathroom, 'Test', None, quantity=-100))
- self.assertEqual(StockItem.objects.filter(part=25).count(), 4)
+ self.assertEqual(StockItem.objects.filter(part=25).count(), 5)
# Try to move to a blank location
self.assertFalse(widget.move(None, 'null', None))
@@ -327,3 +335,99 @@ class StockTest(TestCase):
# Serialize the remainder of the stock
item.serializeStock(2, [99, 100], self.user)
+
+
+class VariantTest(StockTest):
+ """
+ Tests for calculation stock counts against templates / variants
+ """
+
+ def test_variant_stock(self):
+ # Check the 'Chair' variant
+ chair = Part.objects.get(pk=10000)
+
+ # No stock items for the variant part itself
+ self.assertEqual(chair.stock_entries(include_variants=False).count(), 0)
+
+ self.assertEqual(chair.stock_entries().count(), 12)
+
+ green = Part.objects.get(pk=10003)
+ self.assertEqual(green.stock_entries(include_variants=False).count(), 0)
+ self.assertEqual(green.stock_entries().count(), 3)
+
+ def test_serial_numbers(self):
+ # Test serial number functionality for variant / template parts
+
+ chair = Part.objects.get(pk=10000)
+
+ # Operations on the top-level object
+ self.assertTrue(chair.checkIfSerialNumberExists(1))
+ self.assertTrue(chair.checkIfSerialNumberExists(2))
+ self.assertTrue(chair.checkIfSerialNumberExists(3))
+ self.assertTrue(chair.checkIfSerialNumberExists(4))
+ self.assertTrue(chair.checkIfSerialNumberExists(5))
+
+ self.assertTrue(chair.checkIfSerialNumberExists(20))
+ self.assertTrue(chair.checkIfSerialNumberExists(21))
+ self.assertTrue(chair.checkIfSerialNumberExists(22))
+
+ self.assertFalse(chair.checkIfSerialNumberExists(30))
+
+ self.assertEqual(chair.getNextSerialNumber(), 23)
+
+ # Same operations on a sub-item
+ variant = Part.objects.get(pk=10003)
+ self.assertEqual(variant.getNextSerialNumber(), 23)
+
+ # Create a new serial number
+ n = variant.getHighestSerialNumber()
+
+ item = StockItem(
+ part=variant,
+ quantity=1,
+ serial=n
+ )
+
+ # This should fail
+ with self.assertRaises(ValidationError):
+ item.save()
+
+ # This should pass
+ item.serial = n + 1
+ item.save()
+
+ # Attempt to create the same serial number but for a variant (should fail!)
+ item.pk = None
+ item.part = Part.objects.get(pk=10004)
+
+ with self.assertRaises(ValidationError):
+ item.save()
+
+ item.serial += 1
+ item.save()
+
+
+class TestResultTest(StockTest):
+ """
+ Tests for the StockItemTestResult model.
+ """
+
+ def test_test_count(self):
+ item = StockItem.objects.get(pk=105)
+ tests = item.test_results
+ self.assertEqual(tests.count(), 4)
+
+ results = item.getTestResults(test="Temperature Test")
+ self.assertEqual(results.count(), 2)
+
+ # Passing tests
+ self.assertEqual(item.getTestResults(result=True).count(), 3)
+ self.assertEqual(item.getTestResults(result=False).count(), 1)
+
+ # Result map
+ result_map = item.testResultMap()
+
+ self.assertEqual(len(result_map), 3)
+
+ for test in ['Firmware Version', 'Settings Checksum', 'Temperature Test']:
+ self.assertIn(test, result_map.keys())
diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py
index 149c0dde2f..ce99976f8b 100644
--- a/InvenTree/stock/urls.py
+++ b/InvenTree/stock/urls.py
@@ -24,6 +24,7 @@ stock_item_detail_urls = [
url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'),
+ url(r'^test/', views.StockItemDetail.as_view(template_name='stock/item_tests.html'), name='stock-item-test-results'),
url(r'^children/', views.StockItemDetail.as_view(template_name='stock/item_childs.html'), name='stock-item-children'),
url(r'^attachments/', views.StockItemDetail.as_view(template_name='stock/item_attachments.html'), name='stock-item-attachments'),
url(r'^notes/', views.StockItemNotes.as_view(), name='stock-item-notes'),
@@ -51,12 +52,20 @@ stock_urls = [
url(r'^item/new/?', views.StockItemCreate.as_view(), name='stock-item-create'),
+ # URLs for StockItem attachments
url(r'^item/attachment/', include([
url(r'^new/', views.StockItemAttachmentCreate.as_view(), name='stock-item-attachment-create'),
url(r'^(?P\d+)/edit/', views.StockItemAttachmentEdit.as_view(), name='stock-item-attachment-edit'),
url(r'^(?P\d+)/delete/', views.StockItemAttachmentDelete.as_view(), name='stock-item-attachment-delete'),
])),
+ # URLs for StockItem tests
+ url(r'^item/test/', include([
+ url(r'^new/', views.StockItemTestResultCreate.as_view(), name='stock-item-test-create'),
+ url(r'^(?P\d+)/edit/', views.StockItemTestResultEdit.as_view(), name='stock-item-test-edit'),
+ url(r'^(?P\d+)/delete/', views.StockItemTestResultDelete.as_view(), name='stock-item-test-delete'),
+ ])),
+
url(r'^track/', include(stock_tracking_urls)),
url(r'^adjust/?', views.StockAdjust.as_view(), name='stock-adjust'),
diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py
index e616be1f35..39bc7138b1 100644
--- a/InvenTree/stock/views.py
+++ b/InvenTree/stock/views.py
@@ -26,7 +26,7 @@ from datetime import datetime
from company.models import Company, SupplierPart
from part.models import Part
-from .models import StockItem, StockLocation, StockItemTracking, StockItemAttachment
+from .models import StockItem, StockLocation, StockItemTracking, StockItemAttachment, StockItemTestResult
from .admin import StockItemResource
@@ -38,6 +38,7 @@ from .forms import TrackingEntryForm
from .forms import SerializeStockForm
from .forms import ExportOptionsForm
from .forms import EditStockItemAttachmentForm
+from .forms import EditStockItemTestResultForm
class StockIndex(ListView):
@@ -228,6 +229,69 @@ class StockItemAttachmentDelete(AjaxDeleteView):
}
+class StockItemTestResultCreate(AjaxCreateView):
+ """
+ View for adding a new StockItemTestResult
+ """
+
+ model = StockItemTestResult
+ form_class = EditStockItemTestResultForm
+ ajax_form_title = _("Add Test Result")
+
+ def post_save(self, **kwargs):
+ """ Record the user that uploaded the test result """
+
+ self.object.user = self.request.user
+ self.object.save()
+
+ def get_initial(self):
+
+ initials = super().get_initial()
+
+ try:
+ stock_id = self.request.GET.get('stock_item', None)
+ initials['stock_item'] = StockItem.objects.get(pk=stock_id)
+ except (ValueError, StockItem.DoesNotExist):
+ pass
+
+ return initials
+
+ def get_form(self):
+
+ form = super().get_form()
+ form.fields['stock_item'].widget = HiddenInput()
+
+ return form
+
+
+class StockItemTestResultEdit(AjaxUpdateView):
+ """
+ View for editing a StockItemTestResult
+ """
+
+ model = StockItemTestResult
+ form_class = EditStockItemTestResultForm
+ ajax_form_title = _("Edit Test Result")
+
+ def get_form(self):
+
+ form = super().get_form()
+
+ form.fields['stock_item'].widget = HiddenInput()
+
+ return form
+
+
+class StockItemTestResultDelete(AjaxDeleteView):
+ """
+ View for deleting a StockItemTestResult
+ """
+
+ model = StockItemTestResult
+ ajax_form_title = _("Delete Test Result")
+ context_object_name = "result"
+
+
class StockExportOptions(AjaxView):
""" Form for selecting StockExport options """
@@ -717,7 +781,7 @@ class StockItemEdit(AjaxUpdateView):
query = query.filter(part=item.part.id)
form.fields['supplier_part'].queryset = query
- if not item.part.trackable:
+ if not item.part.trackable or not item.serialized:
form.fields.pop('serial')
return form
@@ -757,6 +821,17 @@ class StockItemSerialize(AjaxUpdateView):
ajax_form_title = _('Serialize Stock')
form_class = SerializeStockForm
+ def get_form(self):
+
+ context = self.get_form_kwargs()
+
+ # Pass the StockItem object through to the form
+ context['item'] = self.get_object()
+
+ form = SerializeStockForm(**context)
+
+ return form
+
def get_initial(self):
initials = super().get_initial().copy()
@@ -764,6 +839,7 @@ class StockItemSerialize(AjaxUpdateView):
item = self.get_object()
initials['quantity'] = item.quantity
+ initials['serial_numbers'] = item.part.getSerialNumberString(item.quantity)
initials['destination'] = item.location.pk
return initials
@@ -844,6 +920,8 @@ class StockItemCreate(AjaxCreateView):
form = super().get_form()
+ part = None
+
# If the user has selected a Part, limit choices for SupplierPart
if form['part'].value():
part_id = form['part'].value()
@@ -851,6 +929,11 @@ class StockItemCreate(AjaxCreateView):
try:
part = Part.objects.get(id=part_id)
+ sn = part.getNextSerialNumber()
+ form.field_placeholder['serial_numbers'] = _('Next available serial number is') + ' ' + str(sn)
+
+ form.rebuild_layout()
+
# Hide the 'part' field (as a valid part is selected)
form.fields['part'].widget = HiddenInput()
@@ -873,6 +956,7 @@ class StockItemCreate(AjaxCreateView):
# If there is one (and only one) supplier part available, pre-select it
all_parts = parts.all()
+
if len(all_parts) == 1:
# TODO - This does NOT work for some reason? Ref build.views.BuildItemCreate
@@ -884,7 +968,7 @@ class StockItemCreate(AjaxCreateView):
# Otherwise if the user has selected a SupplierPart, we know what Part they meant!
elif form['supplier_part'].value() is not None:
pass
-
+
return form
def get_initial(self):
@@ -917,6 +1001,7 @@ class StockItemCreate(AjaxCreateView):
if part_id:
try:
part = Part.objects.get(pk=part_id)
+
# Check that the supplied part is 'valid'
if not part.is_template and part.active and not part.virtual:
initials['part'] = part
@@ -954,6 +1039,8 @@ class StockItemCreate(AjaxCreateView):
- Manage serial-number valdiation for tracked parts
"""
+ part = None
+
form = self.get_form()
data = {}
@@ -965,12 +1052,22 @@ class StockItemCreate(AjaxCreateView):
try:
part = Part.objects.get(id=part_id)
quantity = Decimal(form['quantity'].value())
+
+ sn = part.getNextSerialNumber()
+ form.field_placeholder['serial_numbers'] = _("Next available serial number is") + " " + str(sn)
+
+ form.rebuild_layout()
+
except (Part.DoesNotExist, ValueError, InvalidOperation):
part = None
quantity = 1
valid = False
form.errors['quantity'] = [_('Invalid quantity')]
+ if quantity <= 0:
+ form.errors['quantity'] = [_('Quantity must be greater than zero')]
+ valid = False
+
if part is None:
form.errors['part'] = [_('Invalid part selection')]
else:
@@ -988,7 +1085,7 @@ class StockItemCreate(AjaxCreateView):
existing = []
for serial in serials:
- if not StockItem.check_serial_number(part, serial):
+ if part.checkIfSerialNumberExists(serial):
existing.append(serial)
if len(existing) > 0:
@@ -1003,24 +1100,26 @@ class StockItemCreate(AjaxCreateView):
form_data = form.cleaned_data
- for serial in serials:
- # Create a new stock item for each serial number
- item = StockItem(
- part=part,
- quantity=1,
- serial=serial,
- supplier_part=form_data.get('supplier_part'),
- location=form_data.get('location'),
- batch=form_data.get('batch'),
- delete_on_deplete=False,
- status=form_data.get('status'),
- link=form_data.get('link'),
- )
+ if form.is_valid():
- item.save(user=request.user)
+ for serial in serials:
+ # Create a new stock item for each serial number
+ item = StockItem(
+ part=part,
+ quantity=1,
+ serial=serial,
+ supplier_part=form_data.get('supplier_part'),
+ location=form_data.get('location'),
+ batch=form_data.get('batch'),
+ delete_on_deplete=False,
+ status=form_data.get('status'),
+ link=form_data.get('link'),
+ )
- data['success'] = _('Created {n} new stock items'.format(n=len(serials)))
- valid = True
+ item.save(user=request.user)
+
+ data['success'] = _('Created {n} new stock items'.format(n=len(serials)))
+ valid = True
except ValidationError as e:
form.errors['serial_numbers'] = e.messages
@@ -1031,6 +1130,24 @@ class StockItemCreate(AjaxCreateView):
form.clean()
form._post_clean()
+ if form.is_valid():
+
+ item = form.save(commit=False)
+ item.save(user=request.user)
+
+ data['pk'] = item.pk
+ data['url'] = item.get_absolute_url()
+ data['success'] = _("Created new stock item")
+
+ valid = True
+
+ else: # Referenced Part object is not marked as "trackable"
+ # For non-serialized items, simply save the form.
+ # We need to call _post_clean() here because it is prevented in the form implementation
+ form.clean()
+ form._post_clean()
+
+ if form.is_valid:
item = form.save(commit=False)
item.save(user=request.user)
@@ -1038,20 +1155,9 @@ class StockItemCreate(AjaxCreateView):
data['url'] = item.get_absolute_url()
data['success'] = _("Created new stock item")
- else: # Referenced Part object is not marked as "trackable"
- # For non-serialized items, simply save the form.
- # We need to call _post_clean() here because it is prevented in the form implementation
- form.clean()
- form._post_clean()
-
- item = form.save(commit=False)
- item.save(user=request.user)
+ valid = True
- data['pk'] = item.pk
- data['url'] = item.get_absolute_url()
- data['success'] = _("Created new stock item")
-
- data['form_valid'] = valid
+ data['form_valid'] = valid and form.is_valid()
return self.renderJsonResponse(request, form, data=data)
diff --git a/InvenTree/templates/attachment_table.html b/InvenTree/templates/attachment_table.html
index 090ae566f6..32407cbc5b 100644
--- a/InvenTree/templates/attachment_table.html
+++ b/InvenTree/templates/attachment_table.html
@@ -6,6 +6,7 @@
+
@@ -27,7 +28,7 @@
{% endfor %}
-
\ No newline at end of file
+
+
\ No newline at end of file
diff --git a/InvenTree/templates/base.html b/InvenTree/templates/base.html
index fbe8398f27..63487eb625 100644
--- a/InvenTree/templates/base.html
+++ b/InvenTree/templates/base.html
@@ -114,6 +114,7 @@ InvenTree
+
@@ -122,8 +123,6 @@ InvenTree
{% block js_load %}
{% endblock %}
-{% include "table_filters.html" %}
-
\ No newline at end of file