diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index 1220f09ddc..711df33798 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -81,6 +81,7 @@ dynamic_javascript_urls = [ url(r'^order.js', DynamicJsView.as_view(template_name='js/order.html'), name='order.js'), url(r'^company.js', DynamicJsView.as_view(template_name='js/company.html'), name='company.js'), url(r'^bom.js', DynamicJsView.as_view(template_name='js/bom.html'), name='bom.js'), + url(r'^table_filters.js', DynamicJsView.as_view(template_name='js/table_filters.html'), name='table_filters.js'), ] urlpatterns = [ diff --git a/InvenTree/build/templates/build/allocate.html b/InvenTree/build/templates/build/allocate.html index b90508a7d8..61cccae1bf 100644 --- a/InvenTree/build/templates/build/allocate.html +++ b/InvenTree/build/templates/build/allocate.html @@ -198,8 +198,8 @@ InvenTree | Allocate Parts var html = `
`; {% if build.status == BuildStatus.PENDING %} - html += makeIconButton('fa-edit', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}'); - html += makeIconButton('fa-trash-alt', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}'); + html += makeIconButton('fa-edit icon-blue', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}'); + html += makeIconButton('fa-trash-alt icon-red', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}'); {% endif %} html += `
`; @@ -389,7 +389,7 @@ InvenTree | Allocate Parts html += makeIconButton('fa-tools', 'button-build', pk, '{% trans "Build parts" %}'); } - html += makeIconButton('fa-plus', 'button-add', pk, '{% trans "Allocate stock" %}'); + html += makeIconButton('fa-plus icon-green', 'button-add', pk, '{% trans "Allocate stock" %}'); {% endif %} html += ''; diff --git a/InvenTree/order/templates/order/purchase_order_detail.html b/InvenTree/order/templates/order/purchase_order_detail.html index eb796b2ec1..de066500b2 100644 --- a/InvenTree/order/templates/order/purchase_order_detail.html +++ b/InvenTree/order/templates/order/purchase_order_detail.html @@ -208,7 +208,7 @@ $("#po-table").inventreeTable({ var pk = row.pk; {% if order.status == PurchaseOrderStatus.PENDING %} - html += makeIconButton('fa-edit', 'button-line-edit', pk, '{% trans "Edit line item" %}'); + html += makeIconButton('fa-edit icon-blue', 'button-line-edit', pk, '{% trans "Edit line item" %}'); html += makeIconButton('fa-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete line item" %}'); {% endif %} diff --git a/InvenTree/order/templates/order/sales_order_detail.html b/InvenTree/order/templates/order/sales_order_detail.html index 0ca63882b2..75c5fc3d7b 100644 --- a/InvenTree/order/templates/order/sales_order_detail.html +++ b/InvenTree/order/templates/order/sales_order_detail.html @@ -89,8 +89,8 @@ function showAllocationSubTable(index, row, element) { var pk = row.pk; {% if order.status == SalesOrderStatus.PENDING %} - html += makeIconButton('fa-edit', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}'); - html += makeIconButton('fa-trash-alt', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}'); + html += makeIconButton('fa-edit icon-blue', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}'); + html += makeIconButton('fa-trash-alt icon-red', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}'); {% endif %} html += ""; @@ -274,11 +274,11 @@ $("#so-lines-table").inventreeTable({ html += makeIconButton('fa-tools', 'button-build', row.part, '{% trans "Build parts" %}'); } - html += makeIconButton('fa-plus', 'button-add', pk, '{% trans "Allocate parts" %}'); + html += makeIconButton('fa-plus icon-green', 'button-add', pk, '{% trans "Allocate parts" %}'); } - html += makeIconButton('fa-edit', 'button-edit', pk, '{% trans "Edit line item" %}'); - html += makeIconButton('fa-trash-alt', 'button-delete', pk, '{% trans "Delete line item " %}'); + html += makeIconButton('fa-edit icon-blue', 'button-edit', pk, '{% trans "Edit line item" %}'); + html += makeIconButton('fa-trash-alt icon-red', 'button-delete', pk, '{% trans "Delete line item " %}'); html += ``; diff --git a/InvenTree/stock/admin.py b/InvenTree/stock/admin.py index 86099cdbac..333c7d35c1 100644 --- a/InvenTree/stock/admin.py +++ b/InvenTree/stock/admin.py @@ -10,6 +10,7 @@ import import_export.widgets as widgets from .models import StockLocation, StockItem, StockItemAttachment from .models import StockItemTracking +from .models import StockItemTestResult from build.models import Build from company.models import SupplierPart @@ -117,7 +118,13 @@ class StockTrackingAdmin(ImportExportModelAdmin): list_display = ('item', 'date', 'title') +class StockItemTestResultAdmin(admin.ModelAdmin): + + list_display = ('stock_item', 'test', 'result', 'value') + + admin.site.register(StockLocation, LocationAdmin) admin.site.register(StockItem, StockItemAdmin) admin.site.register(StockItemTracking, StockTrackingAdmin) admin.site.register(StockItemAttachment, StockAttachmentAdmin) +admin.site.register(StockItemTestResult, StockItemTestResultAdmin) diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py index 1c654d8b3c..aad36cc3dc 100644 --- a/InvenTree/stock/api.py +++ b/InvenTree/stock/api.py @@ -15,6 +15,7 @@ from django.db.models import Q from .models import StockLocation, StockItem from .models import StockItemTracking from .models import StockItemAttachment +from .models import StockItemTestResult from part.models import Part, PartCategory from part.serializers import PartBriefSerializer @@ -26,6 +27,7 @@ from .serializers import StockItemSerializer from .serializers import LocationSerializer, LocationBriefSerializer from .serializers import StockTrackingSerializer from .serializers import StockItemAttachmentSerializer +from .serializers import StockItemTestResultSerializer from InvenTree.views import TreeSerializer from InvenTree.helpers import str2bool, isNull @@ -536,11 +538,10 @@ class StockList(generics.ListCreateAPIView): try: part = Part.objects.get(pk=part_id) - # If the part is a Template part, select stock items for any "variant" parts under that template - if part.is_template: - queryset = queryset.filter(part__in=[part.id for part in Part.objects.filter(variant_of=part_id)]) - else: - queryset = queryset.filter(part=part_id) + # Filter by any parts "under" the given part + parts = part.get_descendants(include_self=True) + + queryset = queryset.filter(part__in=parts) except (ValueError, Part.DoesNotExist): raise ValidationError({"part": "Invalid Part ID specified"}) @@ -654,11 +655,89 @@ class StockAttachmentList(generics.ListCreateAPIView, AttachmentMixin): queryset = StockItemAttachment.objects.all() serializer_class = StockItemAttachmentSerializer + filter_backends = [ + DjangoFilterBackend, + filters.OrderingFilter, + filters.SearchFilter, + ] + filter_fields = [ 'stock_item', ] +class StockItemTestResultList(generics.ListCreateAPIView): + """ + API endpoint for listing (and creating) a StockItemTestResult object. + """ + + queryset = StockItemTestResult.objects.all() + serializer_class = StockItemTestResultSerializer + + permission_classes = [ + permissions.IsAuthenticated, + ] + + filter_backends = [ + DjangoFilterBackend, + filters.SearchFilter, + filters.OrderingFilter, + ] + + filter_fields = [ + 'stock_item', + 'test', + 'user', + 'result', + 'value', + ] + + def get_serializer(self, *args, **kwargs): + try: + kwargs['user_detail'] = str2bool(self.request.query_params.get('user_detail', False)) + except: + pass + + try: + kwargs['attachment_detail'] = str2bool(self.request.query_params.get('attachment_detail', False)) + except: + pass + + kwargs['context'] = self.get_serializer_context() + + return self.serializer_class(*args, **kwargs) + + def perform_create(self, serializer): + """ + Create a new test result object. + + Also, check if an attachment was uploaded alongside the test result, + and save it to the database if it were. + """ + + # Capture the user information + test_result = serializer.save() + test_result.user = self.request.user + + # Check if a file has been attached to the request + attachment_file = self.request.FILES.get('attachment', None) + + if attachment_file: + # Create a new attachment associated with the stock item + attachment = StockItemAttachment( + attachment=attachment_file, + stock_item=test_result.stock_item, + user=test_result.user + ) + + attachment.save() + + # Link the attachment back to the test result + test_result.attachment = attachment + + test_result.save() + + class StockTrackingList(generics.ListCreateAPIView): """ API endpoint for list view of StockItemTracking objects. @@ -769,6 +848,11 @@ stock_api_urls = [ url(r'^$', StockAttachmentList.as_view(), name='api-stock-attachment-list'), ])), + # Base URL for StockItemTestResult API endpoints + url(r'^test/', include([ + url(r'^$', StockItemTestResultList.as_view(), name='api-stock-test-result-list'), + ])), + url(r'track/?', StockTrackingList.as_view(), name='api-stock-track'), url(r'^tree/?', StockCategoryTree.as_view(), name='api-stock-tree'), diff --git a/InvenTree/stock/fixtures/stock.yaml b/InvenTree/stock/fixtures/stock.yaml index cac34fb01b..156aa8db53 100644 --- a/InvenTree/stock/fixtures/stock.yaml +++ b/InvenTree/stock/fixtures/stock.yaml @@ -70,6 +70,18 @@ lft: 0 rght: 0 +- model: stock.stockitem + pk: 105 + fields: + part: 25 + location: 7 + quantity: 1 + serial: 1000 + level: 0 + tree_id: 0 + lft: 0 + rght: 0 + # Stock items for template / variant parts - model: stock.stockitem pk: 500 diff --git a/InvenTree/stock/fixtures/stock_tests.yaml b/InvenTree/stock/fixtures/stock_tests.yaml new file mode 100644 index 0000000000..9a82b6ada7 --- /dev/null +++ b/InvenTree/stock/fixtures/stock_tests.yaml @@ -0,0 +1,31 @@ +- model: stock.stockitemtestresult + fields: + stock_item: 105 + test: "Firmware Version" + value: "0xA1B2C3D4" + result: True + date: 2020-02-02 + +- model: stock.stockitemtestresult + fields: + stock_item: 105 + test: "Settings Checksum" + value: "0xAABBCCDD" + result: True + date: 2020-02-02 + +- model: stock.stockitemtestresult + fields: + stock_item: 105 + test: "Temperature Test" + result: False + date: 2020-05-16 + notes: 'Got too hot or something' + +- model: stock.stockitemtestresult + fields: + stock_item: 105 + test: "Temperature Test" + result: True + date: 2020-05-17 + notes: 'Passed temperature test by making it cooler' \ No newline at end of file diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index 98a4de56d6..0d543b1315 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -15,7 +15,9 @@ from InvenTree.helpers import GetExportFormats from InvenTree.forms import HelperForm from InvenTree.fields import RoundingDecimalFormField -from .models import StockLocation, StockItem, StockItemTracking, StockItemAttachment +from .models import StockLocation, StockItem, StockItemTracking +from .models import StockItemAttachment +from .models import StockItemTestResult class EditStockItemAttachmentForm(HelperForm): @@ -32,6 +34,22 @@ class EditStockItemAttachmentForm(HelperForm): ] +class EditStockItemTestResultForm(HelperForm): + """ + Form for creating / editing a StockItemTestResult object. + """ + + class Meta: + model = StockItemTestResult + fields = [ + 'stock_item', + 'test', + 'result', + 'value', + 'notes', + ] + + class EditStockLocationForm(HelperForm): """ Form for editing a StockLocation """ diff --git a/InvenTree/stock/migrations/0040_stockitemtestresult.py b/InvenTree/stock/migrations/0040_stockitemtestresult.py new file mode 100644 index 0000000000..fdf0344925 --- /dev/null +++ b/InvenTree/stock/migrations/0040_stockitemtestresult.py @@ -0,0 +1,29 @@ +# Generated by Django 3.0.5 on 2020-05-16 09:55 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('stock', '0039_auto_20200513_0016'), + ] + + operations = [ + migrations.CreateModel( + name='StockItemTestResult', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('test', models.CharField(help_text='Test name', max_length=100, verbose_name='Test')), + ('result', models.BooleanField(default=False, help_text='Test result', verbose_name='Result')), + ('value', models.CharField(blank=True, help_text='Test output value', max_length=500, verbose_name='Value')), + ('date', models.DateTimeField(auto_now_add=True)), + ('attachment', models.ForeignKey(blank=True, help_text='Test result attachment', null=True, on_delete=django.db.models.deletion.SET_NULL, to='stock.StockItemAttachment', verbose_name='Attachment')), + ('stock_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='test_results', to='stock.StockItem')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/InvenTree/stock/migrations/0041_stockitemtestresult_notes.py b/InvenTree/stock/migrations/0041_stockitemtestresult_notes.py new file mode 100644 index 0000000000..1f258a8f6f --- /dev/null +++ b/InvenTree/stock/migrations/0041_stockitemtestresult_notes.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.5 on 2020-05-16 10:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0040_stockitemtestresult'), + ] + + operations = [ + migrations.AddField( + model_name='stockitemtestresult', + name='notes', + field=models.CharField(blank=True, help_text='Test notes', max_length=500, verbose_name='Notes'), + ), + ] diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index bfdb8461e2..a73c7e0a76 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -921,6 +921,51 @@ class StockItem(MPTTModel): return s + def getTestResults(self, test=None, result=None, user=None): + """ + Return all test results associated with this StockItem. + + Optionally can filter results by: + - Test name + - Test result + - User + """ + + results = self.test_results + + if test: + # Filter by test name + results = results.filter(test=test) + + if result is not None: + # Filter by test status + results = results.filter(result=result) + + if user: + # Filter by user + results = results.filter(user=user) + + return results + + def testResultMap(self, **kwargs): + """ + Return a map of test-results using the test name as the key. + Where multiple test results exist for a given name, + the *most recent* test is used. + + This map is useful for rendering to a template (e.g. a test report), + as all named tests are accessible. + """ + + results = self.getTestResults(**kwargs).order_by('-date') + + result_map = {} + + for result in results: + result_map[result.test] = result + + return result_map + @receiver(pre_delete, sender=StockItem, dispatch_uid='stock_item_pre_delete_log') def before_delete_stock_item(sender, instance, using, **kwargs): @@ -993,3 +1038,86 @@ class StockItemTracking(models.Model): # TODO # file = models.FileField() + + +class StockItemTestResult(models.Model): + """ + A StockItemTestResult records results of custom tests against individual StockItem objects. + This is useful for tracking unit acceptance tests, and particularly useful when integrated + with automated testing setups. + + Multiple results can be recorded against any given test, allowing tests to be run many times. + + Attributes: + stock_item: Link to StockItem + test: Test name (simple string matching) + result: Test result value (pass / fail / etc) + value: Recorded test output value (optional) + attachment: Link to StockItem attachment (optional) + notes: Extra user notes related to the test (optional) + user: User who uploaded the test result + date: Date the test result was recorded + """ + + def clean(self): + + super().clean() + + # If an attachment is linked to this result, the attachment must also point to the item + try: + if self.attachment: + if not self.attachment.stock_item == self.stock_item: + raise ValidationError({ + 'attachment': _("Test result attachment must be linked to the same StockItem"), + }) + except (StockItem.DoesNotExist, StockItemAttachment.DoesNotExist): + pass + + stock_item = models.ForeignKey( + StockItem, + on_delete=models.CASCADE, + related_name='test_results' + ) + + test = models.CharField( + blank=False, max_length=100, + verbose_name=_('Test'), + help_text=_('Test name') + ) + + result = models.BooleanField( + default=False, + verbose_name=_('Result'), + help_text=_('Test result') + ) + + value = models.CharField( + blank=True, max_length=500, + verbose_name=_('Value'), + help_text=_('Test output value') + ) + + attachment = models.ForeignKey( + StockItemAttachment, + on_delete=models.SET_NULL, + blank=True, null=True, + verbose_name=_('Attachment'), + help_text=_('Test result attachment'), + ) + + notes = models.CharField( + blank=True, max_length=500, + verbose_name=_('Notes'), + help_text=_("Test notes"), + ) + + user = models.ForeignKey( + User, + on_delete=models.SET_NULL, + blank=True, null=True + ) + + date = models.DateTimeField( + auto_now_add=True, + editable=False + ) diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 622f93e620..865a63a2c2 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -7,6 +7,7 @@ from rest_framework import serializers from .models import StockItem, StockLocation from .models import StockItemTracking from .models import StockItemAttachment +from .models import StockItemTestResult from django.db.models import Sum, Count from django.db.models.functions import Coalesce @@ -193,7 +194,7 @@ class LocationSerializer(InvenTreeModelSerializer): class StockItemAttachmentSerializer(InvenTreeModelSerializer): """ Serializer for StockItemAttachment model """ - def __init_(self, *args, **kwargs): + def __init__(self, *args, **kwargs): user_detail = kwargs.pop('user_detail', False) super().__init__(*args, **kwargs) @@ -211,11 +212,55 @@ class StockItemAttachmentSerializer(InvenTreeModelSerializer): 'stock_item', 'attachment', 'comment', + 'upload_date', 'user', 'user_detail', ] +class StockItemTestResultSerializer(InvenTreeModelSerializer): + """ Serializer for the StockItemTestResult model """ + + user_detail = UserSerializerBrief(source='user', read_only=True) + attachment_detail = StockItemAttachmentSerializer(source='attachment', read_only=True) + + def __init__(self, *args, **kwargs): + user_detail = kwargs.pop('user_detail', False) + attachment_detail = kwargs.pop('attachment_detail', False) + + super().__init__(*args, **kwargs) + + if user_detail is not True: + self.fields.pop('user_detail') + + if attachment_detail is not True: + self.fields.pop('attachment_detail') + + class Meta: + model = StockItemTestResult + + fields = [ + 'pk', + 'stock_item', + 'test', + 'result', + 'value', + 'attachment', + 'attachment_detail', + 'notes', + 'user', + 'user_detail', + 'date' + ] + + read_only_fields = [ + 'pk', + 'attachment', + 'user', + 'date', + ] + + class StockTrackingSerializer(InvenTreeModelSerializer): """ Serializer for StockItemTracking model """ diff --git a/InvenTree/stock/templates/stock/item.html b/InvenTree/stock/templates/stock/item.html index 517acb15fe..f72c910981 100644 --- a/InvenTree/stock/templates/stock/item.html +++ b/InvenTree/stock/templates/stock/item.html @@ -8,8 +8,9 @@ {% include "stock/tabs.html" with tab="tracking" %} -

{% trans "Stock Tracking Information" %}

+
+
diff --git a/InvenTree/stock/templates/stock/item_attachments.html b/InvenTree/stock/templates/stock/item_attachments.html index 453ab7f09b..3861bebe6a 100644 --- a/InvenTree/stock/templates/stock/item_attachments.html +++ b/InvenTree/stock/templates/stock/item_attachments.html @@ -7,8 +7,8 @@ {% include "stock/tabs.html" with tab='attachments' %} -

{% trans "Stock Item Attachments" %}

+
{% include "attachment_table.html" with attachments=item.attachments.all %} diff --git a/InvenTree/stock/templates/stock/item_notes.html b/InvenTree/stock/templates/stock/item_notes.html index 31e818ef71..9dd21331b4 100644 --- a/InvenTree/stock/templates/stock/item_notes.html +++ b/InvenTree/stock/templates/stock/item_notes.html @@ -10,6 +10,7 @@ {% include "stock/tabs.html" with tab="notes" %} {% if editing %} +

{% trans "Stock Item Notes" %}


diff --git a/InvenTree/stock/templates/stock/item_tests.html b/InvenTree/stock/templates/stock/item_tests.html new file mode 100644 index 0000000000..6621637f1f --- /dev/null +++ b/InvenTree/stock/templates/stock/item_tests.html @@ -0,0 +1,76 @@ +{% extends "stock/item_base.html" %} + +{% load static %} +{% load i18n %} + +{% block details %} + +{% include "stock/tabs.html" with tab='tests' %} + +

{% trans "Test Results" %}

+
+ +
+
+
+ +
+
+ +
+
+
+ +
+ +{% endblock %} + +{% block js_ready %} +{{ block.super }} + +loadStockTestResultsTable( + $("#test-result-table"), { + params: { + stock_item: {{ item.id }}, + user_detail: true, + attachment_detail: true, + }, + } +); + +function reloadTable() { + $("#test-result-table").bootstrapTable("refresh"); +} + +$("#add-test-result").click(function() { + launchModalForm( + "{% url 'stock-item-test-create' %}", { + data: { + stock_item: {{ item.id }}, + }, + success: reloadTable, + } + ); +}); + +$("#test-result-table").on('click', '.button-test-edit', function() { + var button = $(this); + + var url = `/stock/item/test/${button.attr('pk')}/edit/`; + + launchModalForm(url, { + success: reloadTable, + }); +}); + +$("#test-result-table").on('click', '.button-test-delete', function() { + var button = $(this); + + var url = `/stock/item/test/${button.attr('pk')}/delete/`; + + launchModalForm(url, { + success: reloadTable, + }); +}); + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/stock/templates/stock/tabs.html b/InvenTree/stock/templates/stock/tabs.html index fddd2f095f..5ec75ddc28 100644 --- a/InvenTree/stock/templates/stock/tabs.html +++ b/InvenTree/stock/templates/stock/tabs.html @@ -1,15 +1,20 @@ {% load i18n %} \ 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 edb9660000..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): @@ -95,8 +96,8 @@ class StockTest(TestCase): self.assertFalse(self.drawer2.has_items()) # Drawer 3 should have three stock items - self.assertEqual(self.drawer3.stock_items.count(), 15) - self.assertEqual(self.drawer3.item_count, 15) + 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) @@ -108,7 +109,7 @@ class StockTest(TestCase): 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): @@ -168,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)) @@ -404,3 +405,29 @@ class VariantTest(StockTest): 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 dc70cc6bfb..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 """ diff --git a/InvenTree/templates/attachment_table.html b/InvenTree/templates/attachment_table.html index 71664a3ccc..32407cbc5b 100644 --- a/InvenTree/templates/attachment_table.html +++ b/InvenTree/templates/attachment_table.html @@ -28,7 +28,7 @@