From 17f241774f7879c65c30137d91fbd65aa325d407 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 17 May 2020 12:59:03 +1000 Subject: [PATCH 01/18] Add function to generate "keys" for test results. - As the keys are to be used for dict-based lookup (in a template) then they cannot contains spaces. - May as well enforce lower-case encoding! --- InvenTree/InvenTree/helpers.py | 14 ++++++++++++++ InvenTree/stock/models.py | 3 ++- InvenTree/stock/tests.py | 3 ++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index 8c5d59181d..3f65312691 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -19,6 +19,20 @@ from .version import inventreeVersion, inventreeInstanceName from .settings import MEDIA_URL, STATIC_URL +def generateTestKey(test_name): + """ + Generate a test 'key' for a given test name. + This must not have spaces as it will be used for dict lookup in a template. + + Tests must be named such that they will have unique keys. + """ + + key = test_name.strip().lower() + key = key.replace(" ", "") + + return key + + def getMediaUrl(filename): """ Return the qualified access path for the given file, diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index a73c7e0a76..6b29c6e5fc 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -962,7 +962,8 @@ class StockItem(MPTTModel): result_map = {} for result in results: - result_map[result.test] = result + key = helpers.generateTestKey(result.test) + result_map[key] = result return result_map diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index 8fce455a97..cd927e0251 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -429,5 +429,6 @@ class TestResultTest(StockTest): self.assertEqual(len(result_map), 3) - for test in ['Firmware Version', 'Settings Checksum', 'Temperature Test']: + # Keys are all lower-case and do not contain spaces + for test in ['firmwareversion', 'settingschecksum', 'temperaturetest']: self.assertIn(test, result_map.keys()) From 8c8b704e38fa6ac326eb2a1f24bcc66df0e657e4 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 17 May 2020 13:26:51 +1000 Subject: [PATCH 02/18] Add PartTestTemplate model --- .../part/migrations/0040_parttesttemplate.py | 23 ++++++ InvenTree/part/models.py | 70 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 InvenTree/part/migrations/0040_parttesttemplate.py diff --git a/InvenTree/part/migrations/0040_parttesttemplate.py b/InvenTree/part/migrations/0040_parttesttemplate.py new file mode 100644 index 0000000000..45e270c88c --- /dev/null +++ b/InvenTree/part/migrations/0040_parttesttemplate.py @@ -0,0 +1,23 @@ +# Generated by Django 3.0.5 on 2020-05-17 03:26 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0039_auto_20200515_1127'), + ] + + operations = [ + migrations.CreateModel( + name='PartTestTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('test_name', models.CharField(help_text='Enter a name for the test', max_length=100, verbose_name='Test name')), + ('required', models.BooleanField(default=True, help_text='Is this test required to pass?', verbose_name='Required')), + ('part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='test_templates', to='part.Part')), + ], + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 7639b9c25b..fea0fbd79b 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -1109,6 +1109,76 @@ class PartStar(models.Model): unique_together = ['part', 'user'] +class PartTestTemplate(models.Model): + """ + A PartTestTemplate defines a 'template' for a test which is required to be run + against a StockItem (an instance of the Part). + + The test template applies "recursively" to part variants, allowing tests to be + defined in a heirarchy. + + Test names are simply strings, rather than enforcing any sort of structure or pattern. + It is up to the user to determine what tests are defined (and how they are run). + + To enable generation of unique lookup-keys for each test, there are some validation tests + run on the model (refer to the validate_unique function). + """ + + def save(self): + + self.validate_unique() + self.clean() + + super().save() + + def validate_unique(self, exclude=None): + """ + Test that this test template is 'unique' within this part tree. + """ + + super().validate_unique(exclude) + + # Get a list of all tests "above" this one + tests = self.objects.filter( + part__in=self.part.get_ancestors(include_self=True) + ) + + # If this item is already in the database, exclude it from comparison! + if self.pk is not None: + tests = tests.exclude(pk=self.pk) + + key = self.key + + for test in tests: + if test.key == key: + raise ValidationError({ + 'test_name': _("Test with this name already exists for this part") + }) + + @property + def key(self): + """ Generate a key for this test """ + return helpers.generateTestKey(self.test_name) + + part = models.ForeignKey( + Part, + on_delete=models.CASCADE, + related_name='test_templates' + ) + + test_name = models.CharField( + blank=False, max_length=100, + verbose_name=_("Test name"), + help_text=_("Enter a name for the test") + ) + + required = models.BooleanField( + default=True, + verbose_name=_("Required"), + help_text=_("Is this test required to pass?") + ) + + class PartParameterTemplate(models.Model): """ A PartParameterTemplate provides a template for key:value pairs for extra From badf9230a9e613821e7df927433a50771ecb8468 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 17 May 2020 13:46:19 +1000 Subject: [PATCH 03/18] Add fixtures / unit testing for the stock item testing framework --- InvenTree/part/fixtures/test_templates.yaml | 39 +++++++++++++ InvenTree/part/models.py | 26 ++++++++- InvenTree/part/test_part.py | 61 ++++++++++++++++++++- 3 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 InvenTree/part/fixtures/test_templates.yaml diff --git a/InvenTree/part/fixtures/test_templates.yaml b/InvenTree/part/fixtures/test_templates.yaml new file mode 100644 index 0000000000..f939dea0c1 --- /dev/null +++ b/InvenTree/part/fixtures/test_templates.yaml @@ -0,0 +1,39 @@ +# Tests for the top-level "chair" part +- model: part.parttesttemplate + fields: + part: 10000 + test_name: Test strength of chair + +- model: part.parttesttemplate + fields: + part: 10000 + test_name: Apply paint + +- model: part.parttesttemplate + fields: + part: 10000 + test_name: Sew cushion + +- model: part.parttesttemplate + fields: + part: 10000 + test_name: Attach legs + +- model: part.parttesttemplate + fields: + part: 10000 + test_name: Record weight + required: false + +# Add some tests for one of the variants +- model: part.parttesttemplate + fields: + part: 10003 + test_name: Check that chair is green + required: true + +- model: part.parttesttemplate + fields: + part: 10004 + test_name: Check that chair is especially green + required: False \ No newline at end of file diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index fea0fbd79b..94e1efb202 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -990,6 +990,26 @@ class Part(MPTTModel): self.save() + def getTestTemplates(self, required=None, include_parent=True): + """ + Return a list of all test templates associated with this Part. + These are used for validation of a StockItem. + + args: + required: Set to True or False to filter by "required" status + include_parent: Set to True to traverse upwards + """ + + if include_parent: + tests = PartTestTemplate.objects.filter(part__in=self.get_ancestors(include_self=True)) + else: + tests = self.test_templates + + if required is not None: + tests = tests.filter(required=required) + + return tests + @property def attachment_count(self): """ Count the number of attachments for this part. @@ -1124,12 +1144,12 @@ class PartTestTemplate(models.Model): run on the model (refer to the validate_unique function). """ - def save(self): + def save(self, *args, **kwargs): self.validate_unique() self.clean() - super().save() + super().save(*args, **kwargs) def validate_unique(self, exclude=None): """ @@ -1139,7 +1159,7 @@ class PartTestTemplate(models.Model): super().validate_unique(exclude) # Get a list of all tests "above" this one - tests = self.objects.filter( + tests = PartTestTemplate.objects.filter( part__in=self.part.get_ancestors(include_self=True) ) diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index 622f0af547..4418c64db5 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -5,10 +5,11 @@ from __future__ import unicode_literals from django.test import TestCase +from django.core.exceptions import ValidationError import os -from .models import Part +from .models import Part, PartTestTemplate from .models import rename_part_image, match_part_names from .templatetags import inventree_extras @@ -105,3 +106,61 @@ class PartTest(TestCase): matches = match_part_names('M2x5 LPHS') self.assertTrue(len(matches) > 0) + + +class TestTemplateTest(TestCase): + + fixtures = [ + 'category', + 'part', + 'location', + 'test_templates', + ] + + def test_template_count(self): + + chair = Part.objects.get(pk=10000) + + # Tests for the top-level chair object (nothing above it!) + self.assertEqual(chair.test_templates.count(), 5) + self.assertEqual(chair.getTestTemplates().count(), 5) + self.assertEqual(chair.getTestTemplates(required=True).count(), 4) + self.assertEqual(chair.getTestTemplates(required=False).count(), 1) + + # Test the lowest-level part which has more associated tests + variant = Part.objects.get(pk=10004) + + self.assertEqual(variant.getTestTemplates().count(), 7) + self.assertEqual(variant.getTestTemplates(include_parent=False).count(), 1) + self.assertEqual(variant.getTestTemplates(required=True).count(), 5) + + def test_uniqueness(self): + # Test names must be unique for this part and also parts above + + variant = Part.objects.get(pk=10004) + + with self.assertRaises(ValidationError): + PartTestTemplate.objects.create( + part=variant, + test_name='Record weight' + ) + + with self.assertRaises(ValidationError): + PartTestTemplate.objects.create( + part=variant, + test_name='Check that chair is especially green' + ) + + # Also should fail if we attempt to create a test that would generate the same key + with self.assertRaises(ValidationError): + PartTestTemplate.objects.create( + part=variant, + test_name='ReCoRD weiGHT ' + ) + + # But we should be able to create a new one! + n = variant.getTestTemplates().count() + + PartTestTemplate.objects.create(part=variant, test_name='A Sample Test') + + self.assertEqual(variant.getTestTemplates().count(), n + 1) \ No newline at end of file From f791ac9f579cfb03449498238a23dff6ba2d6bfa Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 17 May 2020 13:50:06 +1000 Subject: [PATCH 04/18] Register test template in the admin interface --- InvenTree/part/admin.py | 7 +++++++ .../migrations/0041_auto_20200517_0348.py | 19 +++++++++++++++++++ InvenTree/part/models.py | 3 ++- InvenTree/part/test_part.py | 2 +- InvenTree/stock/models.py | 2 +- 5 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 InvenTree/part/migrations/0041_auto_20200517_0348.py diff --git a/InvenTree/part/admin.py b/InvenTree/part/admin.py index fc5727bd9d..de1b7d1fae 100644 --- a/InvenTree/part/admin.py +++ b/InvenTree/part/admin.py @@ -12,6 +12,7 @@ from .models import PartCategory, Part from .models import PartAttachment, PartStar from .models import BomItem from .models import PartParameterTemplate, PartParameter +from .models import PartTestTemplate from stock.models import StockLocation from company.models import SupplierPart @@ -126,6 +127,11 @@ class PartStarAdmin(admin.ModelAdmin): list_display = ('part', 'user') +class PartTestTemplateAdmin(admin.ModelAdmin): + + list_display = ('part', 'test_name', 'required') + + class BomItemResource(ModelResource): """ Class for managing BomItem data import/export """ @@ -202,3 +208,4 @@ admin.site.register(PartStar, PartStarAdmin) admin.site.register(BomItem, BomItemAdmin) admin.site.register(PartParameterTemplate, ParameterTemplateAdmin) admin.site.register(PartParameter, ParameterAdmin) +admin.site.register(PartTestTemplate, PartTestTemplateAdmin) diff --git a/InvenTree/part/migrations/0041_auto_20200517_0348.py b/InvenTree/part/migrations/0041_auto_20200517_0348.py new file mode 100644 index 0000000000..ff24313193 --- /dev/null +++ b/InvenTree/part/migrations/0041_auto_20200517_0348.py @@ -0,0 +1,19 @@ +# Generated by Django 3.0.5 on 2020-05-17 03:48 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0040_parttesttemplate'), + ] + + operations = [ + migrations.AlterField( + model_name='parttesttemplate', + name='part', + field=models.ForeignKey(limit_choices_to={'trackable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='test_templates', to='part.Part'), + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 94e1efb202..76a5965aae 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -1183,7 +1183,8 @@ class PartTestTemplate(models.Model): part = models.ForeignKey( Part, on_delete=models.CASCADE, - related_name='test_templates' + related_name='test_templates', + limit_choices_to={'trackable': True}, ) test_name = models.CharField( diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index 4418c64db5..de2edcec98 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -163,4 +163,4 @@ class TestTemplateTest(TestCase): PartTestTemplate.objects.create(part=variant, test_name='A Sample Test') - self.assertEqual(variant.getTestTemplates().count(), n + 1) \ No newline at end of file + self.assertEqual(variant.getTestTemplates().count(), n + 1) diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 6b29c6e5fc..851c80a9ed 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -962,7 +962,7 @@ class StockItem(MPTTModel): result_map = {} for result in results: - key = helpers.generateTestKey(result.test) + key = helpers.generateTestKey(result.test) result_map[key] = result return result_map From 4d992ea52851fca1853d5bbf5c2159825caa43fd Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 17 May 2020 13:56:49 +1000 Subject: [PATCH 05/18] Expose test templates to the API --- InvenTree/part/api.py | 48 +++++++++++++++++++++++++++++++++-- InvenTree/part/serializers.py | 17 +++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 84ff83af0a..26210badd6 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -19,7 +19,7 @@ from django.urls import reverse from .models import Part, PartCategory, BomItem, PartStar from .models import PartParameter, PartParameterTemplate -from .models import PartAttachment +from .models import PartAttachment, PartTestTemplate from . import serializers as part_serializers @@ -120,6 +120,45 @@ class PartAttachmentList(generics.ListCreateAPIView, AttachmentMixin): ] +class PartTestTemplateList(generics.ListCreateAPIView): + """ + API endpoint for listing (and creating) a PartTestTemplate. + """ + + queryset = PartTestTemplate.objects.all() + serializer_class = part_serializers.PartTestTemplateSerializer + + def filter_queryset(self, queryset): + """ + Filter the test list queryset. + + If filtering by 'part', we include results for any parts "above" the specified part. + """ + + queryset = super().filter_queryset(queryset) + + params = self.request.query_params + + part = params.get('part', None) + + # Filter by part + if part: + try: + part = Part.objects.get(pk=part) + queryset = queryset.filter(part__in=part.get_ancestors(include_self=True)) + except (ValueError, Part.DoesNotExist): + pass + + return queryset + + permission_classes = [permissions.IsAuthenticated] + + filter_backends = [ + DjangoFilterBackend, + filters.OrderingFilter, + filters.SearchFilter, + ] + class PartThumbs(generics.ListAPIView): """ API endpoint for retrieving information on available Part thumbnails """ @@ -635,8 +674,13 @@ part_api_urls = [ url(r'^$', CategoryList.as_view(), name='api-part-category-list'), ])), + # Base URL for PartTestTemplate API endpoints + url(r'^test-template/', include([ + url(r'^$', PartTestTemplateList.as_view(), name='api-part-test-template-list'), + ])), + # Base URL for PartAttachment API endpoints - url(r'attachment/', include([ + url(r'^attachment/', include([ url(r'^$', PartAttachmentList.as_view(), name='api-part-attachment-list'), ])), diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 74005dd5af..396f19ea58 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -10,6 +10,7 @@ from .models import PartCategory from .models import BomItem from .models import PartParameter, PartParameterTemplate from .models import PartAttachment +from .models import PartTestTemplate from decimal import Decimal @@ -56,6 +57,22 @@ class PartAttachmentSerializer(InvenTreeModelSerializer): ] +class PartTestTemplateSerializer(InvenTreeModelSerializer): + """ + Serializer for the PartTestTemplate class + """ + + class Meta: + model = PartTestTemplate + + fields = [ + 'pk', + 'part', + 'test_name', + 'required' + ] + + class PartThumbSerializer(serializers.Serializer): """ Serializer for the 'image' field of the Part model. From 95d07cd02b6c56f64bceca04c1d0164ba8572261 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 17 May 2020 14:14:54 +1000 Subject: [PATCH 06/18] Add unit testing for new API features --- InvenTree/part/fixtures/part.yaml | 7 +++- InvenTree/part/models.py | 15 +++++++-- InvenTree/part/test_api.py | 54 +++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/InvenTree/part/fixtures/part.yaml b/InvenTree/part/fixtures/part.yaml index 035049fe81..76a073261b 100644 --- a/InvenTree/part/fixtures/part.yaml +++ b/InvenTree/part/fixtures/part.yaml @@ -106,6 +106,7 @@ name: 'Chair Template' description: 'A chair' is_template: True + trackable: true category: 7 tree_id: 1 level: 0 @@ -117,6 +118,7 @@ fields: name: 'Blue Chair' variant_of: 10000 + trackable: true category: 7 tree_id: 1 level: 0 @@ -128,6 +130,7 @@ fields: name: 'Red chair' variant_of: 10000 + trackable: true category: 7 tree_id: 1 level: 0 @@ -140,6 +143,7 @@ name: 'Green chair' variant_of: 10000 category: 7 + trackable: true tree_id: 1 level: 0 lft: 0 @@ -150,7 +154,8 @@ fields: name: 'Green chair variant' variant_of: 10003 - category: + category: 7 + trackable: true tree_id: 1 level: 0 lft: 0 diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 76a5965aae..81b8206469 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -1146,17 +1146,26 @@ class PartTestTemplate(models.Model): def save(self, *args, **kwargs): - self.validate_unique() self.clean() super().save(*args, **kwargs) + def clean(self): + + self.test_name = self.test_name.strip() + + self.validate_unique() + super().clean() + def validate_unique(self, exclude=None): """ Test that this test template is 'unique' within this part tree. """ - super().validate_unique(exclude) + if not self.part.trackable: + raise ValidationError({ + 'part': _('Test templates can only be created for trackable parts') + }) # Get a list of all tests "above" this one tests = PartTestTemplate.objects.filter( @@ -1175,6 +1184,8 @@ class PartTestTemplate(models.Model): 'test_name': _("Test with this name already exists for this part") }) + super().validate_unique(exclude) + @property def key(self): """ Generate a key for this test """ diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 61e7f4ab32..9fcf98d712 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -16,6 +16,7 @@ class PartAPITest(APITestCase): 'part', 'location', 'bom', + 'test_templates', ] def setUp(self): @@ -159,3 +160,56 @@ class PartAPITest(APITestCase): data['part'] = 2 data['sub_part'] = 2 response = self.client.post(url, data, format='json') + + def test_test_templates(self): + + url = reverse('api-part-test-template-list') + + # List ALL items + response = self.client.get(url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 7) + + # Request for a particular part + response = self.client.get(url, data={'part': 10000}) + self.assertEqual(len(response.data), 5) + + response = self.client.get(url, data={'part': 10004}) + self.assertEqual(len(response.data), 7) + + # Try to post a new object (should succeed) + response = self.client.post( + url, + data={ + 'part': 10000, + 'test_name': 'New Test', + 'required': True, + }, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + # Try to post a new test with the same name (should fail) + response = self.client.post( + url, + data={ + 'part': 10004, + 'test_name': " newtest" + }, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + # Try to post a new test against a non-trackable part (should fail) + response = self.client.post( + url, + data={ + 'part': 1, + 'test_name': 'A simple test', + } + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) From 69c748d018619365f42015a48546d561d195abeb Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 17 May 2020 14:15:13 +1000 Subject: [PATCH 07/18] PEP fix --- InvenTree/part/api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 26210badd6..c66e4c2696 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -159,6 +159,7 @@ class PartTestTemplateList(generics.ListCreateAPIView): filters.SearchFilter, ] + class PartThumbs(generics.ListAPIView): """ API endpoint for retrieving information on available Part thumbnails """ From bc8b3a68f0ae0215504fc4b9ee1c086d588f0f8f Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 17 May 2020 14:17:55 +1000 Subject: [PATCH 08/18] Fixes for unit testing --- InvenTree/part/test_category.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/InvenTree/part/test_category.py b/InvenTree/part/test_category.py index 99d4bce796..40f0f113a0 100644 --- a/InvenTree/part/test_category.py +++ b/InvenTree/part/test_category.py @@ -88,9 +88,9 @@ class CategoryTest(TestCase): self.assertEqual(self.electronics.partcount(), 3) - self.assertEqual(self.mechanical.partcount(), 8) - self.assertEqual(self.mechanical.partcount(active=True), 7) - self.assertEqual(self.mechanical.partcount(False), 6) + self.assertEqual(self.mechanical.partcount(), 9) + self.assertEqual(self.mechanical.partcount(active=True), 8) + self.assertEqual(self.mechanical.partcount(False), 7) self.assertEqual(self.electronics.item_count, self.electronics.partcount()) From 66f2c01d5d3d6d82102833e80e63db5b2b91eddd Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 17 May 2020 14:26:53 +1000 Subject: [PATCH 09/18] Add (empty) page for displaying part test templates --- InvenTree/part/templates/part/part_tests.html | 29 +++++++++++++++++++ InvenTree/part/templates/part/tabs.html | 10 ++++++- InvenTree/part/urls.py | 1 + .../stock/templates/stock/item_tests.html | 2 +- InvenTree/stock/templates/stock/tabs.html | 2 +- 5 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 InvenTree/part/templates/part/part_tests.html diff --git a/InvenTree/part/templates/part/part_tests.html b/InvenTree/part/templates/part/part_tests.html new file mode 100644 index 0000000000..bb0dbb7683 --- /dev/null +++ b/InvenTree/part/templates/part/part_tests.html @@ -0,0 +1,29 @@ +{% extends "part/part_base.html" %} +{% load static %} +{% load i18n %} +{% block details %} + +{% include 'part/tabs.html' with tab='tests' %} + +

{% trans "Part Test Templates" %}

+
+ +
+
+
+ +
+
+ +
+
+
+ +
+ +{% endblock %} + +{% block js_ready %} +{{ block.super }} + +{% endblock %} \ No newline at end of file diff --git a/InvenTree/part/templates/part/tabs.html b/InvenTree/part/templates/part/tabs.html index b47ebfe329..28aa2cbb4d 100644 --- a/InvenTree/part/templates/part/tabs.html +++ b/InvenTree/part/templates/part/tabs.html @@ -48,7 +48,9 @@ {% trans "Sales Orders" %} {{ part.sales_orders|length }} {% endif %} - {% if 0 and part.trackable %} + {% if part.trackable %} + {% if 0 %} + {% trans "Tracking" %} {% if parts.serials.all|length > 0 %} @@ -56,6 +58,12 @@ {% endif %} {% endif %} + + {% trans "Tests" %} + {% if part.getTestTemplates.count > 0 %}{{ part.getTestTemplates.count }}{% endif %} + + + {% endif %} {% trans "Attachments" %} {% if part.attachment_count > 0 %}{{ part.attachment_count }}{% endif %} diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index 10db202fb9..a5aa412b24 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -52,6 +52,7 @@ part_detail_urls = [ url(r'^suppliers/?', views.PartDetail.as_view(template_name='part/supplier.html'), name='part-suppliers'), url(r'^orders/?', views.PartDetail.as_view(template_name='part/orders.html'), name='part-orders'), url(r'^sales-orders/', views.PartDetail.as_view(template_name='part/sales_orders.html'), name='part-sales-orders'), + url(r'^tests/', views.PartDetail.as_view(template_name='part/part_tests.html'), name='part-test-templates'), url(r'^track/?', views.PartDetail.as_view(template_name='part/track.html'), name='part-track'), url(r'^attachments/?', views.PartDetail.as_view(template_name='part/attachments.html'), name='part-attachments'), url(r'^notes/?', views.PartNotes.as_view(), name='part-notes'), diff --git a/InvenTree/stock/templates/stock/item_tests.html b/InvenTree/stock/templates/stock/item_tests.html index 6621637f1f..bd16ee8d29 100644 --- a/InvenTree/stock/templates/stock/item_tests.html +++ b/InvenTree/stock/templates/stock/item_tests.html @@ -13,7 +13,7 @@
- +
diff --git a/InvenTree/stock/templates/stock/tabs.html b/InvenTree/stock/templates/stock/tabs.html index 5ec75ddc28..1e6e9ddb3b 100644 --- a/InvenTree/stock/templates/stock/tabs.html +++ b/InvenTree/stock/templates/stock/tabs.html @@ -10,7 +10,7 @@ {% if item.part.trackable %} - {% trans "Test Results" %} + {% trans "Test Data" %} {% if item.test_results.count > 0 %}{{ item.test_results.count }}{% endif %} From e9ed50fc4bc8a5ff205dce741a69902f5bcfd37c Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 17 May 2020 16:07:55 +1000 Subject: [PATCH 10/18] Add table displaying part test templates --- InvenTree/part/api.py | 6 ++ InvenTree/part/templates/part/part_tests.html | 10 +++ InvenTree/templates/js/part.html | 83 ++++++++++++++++++- InvenTree/templates/js/table_filters.html | 12 ++- 4 files changed, 109 insertions(+), 2 deletions(-) diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index c66e4c2696..1b672d4c68 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -149,6 +149,12 @@ class PartTestTemplateList(generics.ListCreateAPIView): except (ValueError, Part.DoesNotExist): pass + # Filter by 'required' status + required = params.get('required', None) + + if required is not None: + queryset = queryset.filter(required=required) + return queryset permission_classes = [permissions.IsAuthenticated] diff --git a/InvenTree/part/templates/part/part_tests.html b/InvenTree/part/templates/part/part_tests.html index bb0dbb7683..0d6fb56668 100644 --- a/InvenTree/part/templates/part/part_tests.html +++ b/InvenTree/part/templates/part/part_tests.html @@ -26,4 +26,14 @@ {% block js_ready %} {{ block.super }} +loadPartTestTemplateTable( + $("#test-template-table"), + { + part: {{ part.pk }}, + params: { + part: {{ part.pk }}, + } + } +); + {% endblock %} \ No newline at end of file diff --git a/InvenTree/templates/js/part.html b/InvenTree/templates/js/part.html index 57e7fa5798..528fc9aa93 100644 --- a/InvenTree/templates/js/part.html +++ b/InvenTree/templates/js/part.html @@ -284,4 +284,85 @@ function loadPartTable(table, url, options={}) { location.href = '/part/export/?parts=' + parts; }); -} \ No newline at end of file +} + + +function loadPartTestTemplateTable(table, options) { + /* + * Load PartTestTemplate table. + */ + + var params = options.params || {}; + + var part = options.part || null; + + var filterListElement = options.filterList || '#filter-list-parttests'; + + var filters = loadTableFilters("parttests"); + + var original = {}; + + for (var key in params) { + original[key] = params[key]; + } + + setupFilterList("parttests", table, filterListElement); + + // Override the default values, or add new ones + for (var key in params) { + filters[key] = params[key]; + } + + table.inventreeTable({ + method: 'get', + formatNoMatches: function() { + return '{% trans "No test templates matching query" %}'; + }, + url: "{% url 'api-part-test-template-list' %}", + queryParams: filters, + original: original, + columns: [ + { + field: 'pk', + title: 'ID', + visible: false, + }, + { + field: 'test_name', + title: "{% trans "Test Name" %}", + sortable: true, + }, + { + field: 'required', + title: "{% trans 'Required' %}", + sortable: true, + formatter: function(value) { + if (value) { + return `{% trans "YES" %}`; + } else { + return `{% trans "NO" %}`; + } + } + }, + { + field: 'buttons', + formatter: function(value, row) { + var pk = row.pk; + + if (row.part == part) { + var html = `
`; + + html += makeIconButton('fa-edit icon-blue', 'button-test-edit', pk, '{% trans "Edit test result" %}'); + html += makeIconButton('fa-trash-alt icon-red', 'button-test-delete', pk, '{% trans "Delete test result" %}'); + + html += `
`; + + return html; + } else { + return '{% trans "This test is defined for a parent part" %}'; + } + } + } + ] + }); +} diff --git a/InvenTree/templates/js/table_filters.html b/InvenTree/templates/js/table_filters.html index 955703a7c9..84b517e8e7 100644 --- a/InvenTree/templates/js/table_filters.html +++ b/InvenTree/templates/js/table_filters.html @@ -44,7 +44,17 @@ function getAvailableTableFilters(tableKey) { type: 'bool', title: "{% trans 'Test result' %}", }, - } + }; + } + + // Filters for the 'part test template' table + if (tableKey == 'parttests') { + return { + required: { + type: 'bool', + title: "{% trans "Required" %}", + } + }; } // Filters for the "Build" table From cd0e66e3c6a010751bd343b950b0a4dcdd0c93e7 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 17 May 2020 16:17:05 +1000 Subject: [PATCH 11/18] Add ability to edit / assign attatched files to test result data --- InvenTree/InvenTree/models.py | 3 +++ InvenTree/stock/forms.py | 1 + InvenTree/stock/views.py | 13 +++++++++++++ 3 files changed, 17 insertions(+) diff --git a/InvenTree/InvenTree/models.py b/InvenTree/InvenTree/models.py index 5ec2e2945d..d0ed73f27a 100644 --- a/InvenTree/InvenTree/models.py +++ b/InvenTree/InvenTree/models.py @@ -53,6 +53,9 @@ class InvenTreeAttachment(models.Model): return "attachments" + def __str__(self): + return os.path.basename(self.attachment.name) + attachment = models.FileField(upload_to=rename_attachment, help_text=_('Select file to attach')) diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py index 0d543b1315..9576447997 100644 --- a/InvenTree/stock/forms.py +++ b/InvenTree/stock/forms.py @@ -46,6 +46,7 @@ class EditStockItemTestResultForm(HelperForm): 'test', 'result', 'value', + 'attachment', 'notes', ] diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 39bc7138b1..11c2065c35 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -261,6 +261,17 @@ class StockItemTestResultCreate(AjaxCreateView): form = super().get_form() form.fields['stock_item'].widget = HiddenInput() + # Extract the StockItem object + item_id = form['stock_item'].value() + + # Limit the options for the file attachments + try: + stock_item = StockItem.objects.get(pk=item_id) + form.fields['attachment'].queryset = stock_item.attachments.all() + except (ValueError, StockItem.DoesNotExist): + # Hide the attachments field + form.fields['attachment'].widget = HiddenInput() + return form @@ -278,6 +289,8 @@ class StockItemTestResultEdit(AjaxUpdateView): form = super().get_form() form.fields['stock_item'].widget = HiddenInput() + + form.fields['attachment'].queryset = self.object.stock_item.attachments.all() return form From e30f6ec374c19cb26c47c1fad363dfb4ab0394fd Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 17 May 2020 16:50:34 +1000 Subject: [PATCH 12/18] Add forms / views for creating / editing / deleting test templates --- InvenTree/part/forms.py | 14 ++++++ InvenTree/part/templates/part/part_tests.html | 36 +++++++++++++ InvenTree/part/urls.py | 7 +++ InvenTree/part/views.py | 50 +++++++++++++++++++ 4 files changed, 107 insertions(+) diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py index 4d70ab927a..d72d29669b 100644 --- a/InvenTree/part/forms.py +++ b/InvenTree/part/forms.py @@ -15,6 +15,7 @@ from django.utils.translation import ugettext as _ from .models import Part, PartCategory, PartAttachment from .models import BomItem from .models import PartParameterTemplate, PartParameter +from .models import PartTestTemplate from common.models import Currency @@ -29,6 +30,19 @@ class PartImageForm(HelperForm): ] +class EditPartTestTemplateForm(HelperForm): + """ Class for creating / editing a PartTestTemplate object """ + + class Meta: + model = PartTestTemplate + + fields = [ + 'part', + 'test_name', + 'required' + ] + + class BomExportForm(forms.Form): """ Simple form to let user set BOM export options, before exporting a BOM (bill of materials) file. diff --git a/InvenTree/part/templates/part/part_tests.html b/InvenTree/part/templates/part/part_tests.html index 0d6fb56668..5728f2205e 100644 --- a/InvenTree/part/templates/part/part_tests.html +++ b/InvenTree/part/templates/part/part_tests.html @@ -36,4 +36,40 @@ loadPartTestTemplateTable( } ); +function reloadTable() { + $("#test-template-table").bootstrapTable("refresh"); +} + +$("#add-test-template").click(function() { + launchModalForm( + "{% url 'part-test-template-create' %}", + { + data: { + part: {{ part.id }}, + }, + success: reloadTable, + } + ); +}); + +$("#test-template-table").on('click', '.button-test-edit', function() { + var button = $(this); + + var url = `/part/test-template/${button.attr('pk')}/edit/`; + + launchModalForm(url, { + success: reloadTable, + }); +}); + +$("#test-template-table").on('click', '.button-test-delete', function() { + var button = $(this); + + var url = `/part/test-template/${button.attr('pk')}/delete/`; + + launchModalForm(url, { + success: reloadTable, + }); +}); + {% endblock %} \ No newline at end of file diff --git a/InvenTree/part/urls.py b/InvenTree/part/urls.py index a5aa412b24..7d5c279e75 100644 --- a/InvenTree/part/urls.py +++ b/InvenTree/part/urls.py @@ -108,6 +108,13 @@ part_urls = [ # Part attachments url(r'^attachment/', include(part_attachment_urls)), + # Part test templates + url(r'^test-template/', include([ + url(r'^new/', views.PartTestTemplateCreate.as_view(), name='part-test-template-create'), + url(r'^(?P\d+)/edit/', views.PartTestTemplateEdit.as_view(), name='part-test-template-edit'), + url(r'^(?P\d+)/delete/', views.PartTestTemplateDelete.as_view(), name='part-test-template-delete'), + ])), + # Part parameters url(r'^parameter/', include(part_parameter_urls)), diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index ce2fc415be..c81df31484 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -25,6 +25,7 @@ from .models import PartCategory, Part, PartAttachment from .models import PartParameterTemplate, PartParameter from .models import BomItem from .models import match_part_names +from .models import PartTestTemplate from common.models import Currency, InvenTreeSetting from company.models import SupplierPart @@ -149,6 +150,55 @@ class PartAttachmentDelete(AjaxDeleteView): } +class PartTestTemplateCreate(AjaxCreateView): + """ View for creating a PartTestTemplate """ + + model = PartTestTemplate + form_class = part_forms.EditPartTestTemplateForm + ajax_form_title = _("Create Test Template") + + def get_initial(self): + + initials = super().get_initial() + + try: + part_id = self.request.GET.get('part', None) + initials['part'] = Part.objects.get(pk=part_id) + except (ValueError, Part.DoesNotExist): + pass + + return initials + + def get_form(self): + + form = super().get_form() + form.fields['part'].widget = HiddenInput() + + return form + + +class PartTestTemplateEdit(AjaxUpdateView): + """ View for editing a PartTestTemplate """ + + model = PartTestTemplate + form_class = part_forms.EditPartTestTemplateForm + ajax_form_title = _("Edit Test Template") + + def get_form(self): + + form = super().get_form() + form.fields['part'].widget = HiddenInput() + + return form + + +class PartTestTemplateDelete(AjaxDeleteView): + """ View for deleting a PartTestTemplate """ + + model = PartTestTemplate + ajax_form_title = _("Delete Test Template") + + class PartSetCategory(AjaxUpdateView): """ View for settings the part category for multiple parts at once """ From b9799e18248660b49bba02377f7aeaeab2ff0d19 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 17 May 2020 17:05:04 +1000 Subject: [PATCH 13/18] Add some more part slidies --- InvenTree/part/forms.py | 2 -- InvenTree/part/templates/part/detail.html | 20 +++++++++++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py index d72d29669b..a276d62c54 100644 --- a/InvenTree/part/forms.py +++ b/InvenTree/part/forms.py @@ -139,13 +139,11 @@ class EditPartForm(HelperForm): 'revision', 'keywords', 'variant_of', - 'is_template', 'link', 'default_location', 'default_supplier', 'units', 'minimum_stock', - 'active', ] diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index 6d7400d4da..e9e09959fb 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -128,6 +128,15 @@ {% trans "Part is not a virtual part" %} {% endif %} + + {% trans "Template" %} + {% include "slide.html" with state=part.is_template field='is_template' %} + {% if part.is_template %} + {% trans "Part is a template part (variants can be made from this part)" %} + {% else %} + {% trans "Part is not a template part" %} + {% endif %} + {% trans "Assembly" %} {% include "slide.html" with state=part.assembly field='assembly' %} @@ -173,6 +182,15 @@ {% trans "Part cannot be sold to customers" %} {% endif %} + + {% trans "Active" %} + {% include "slide.html" with state=part.active field='active' %} + {% if part.active %} + {% trans "Part is active" %} + {% else %} + {% trans "Part is not active" %} + {% endif %} +
@@ -196,7 +214,7 @@ data[field] = checked; // Update the particular field - inventreePut("/api/part/{{ part.id }}/", + inventreePut("{% url 'api-part-detail' part.id %}", data, { method: 'PATCH', From 8ace71ef56343d8b7bad76e7011e0c8cb5146b32 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 17 May 2020 21:09:49 +1000 Subject: [PATCH 14/18] Complete refactor of the test-result table for stock item --- InvenTree/InvenTree/helpers.py | 5 +- .../stock/templates/stock/item_tests.html | 28 ++- InvenTree/stock/views.py | 2 + InvenTree/templates/js/stock.html | 181 ++++++++++++------ 4 files changed, 151 insertions(+), 65 deletions(-) diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index 3f65312691..9880662e63 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -22,7 +22,7 @@ from .settings import MEDIA_URL, STATIC_URL def generateTestKey(test_name): """ Generate a test 'key' for a given test name. - This must not have spaces as it will be used for dict lookup in a template. + This must not have illegal chars as it will be used for dict lookup in a template. Tests must be named such that they will have unique keys. """ @@ -30,6 +30,9 @@ def generateTestKey(test_name): key = test_name.strip().lower() key = key.replace(" ", "") + # Remove any characters that cannot be used to represent a variable + key = re.sub(r'[^a-zA-Z0-9]', '', key) + return key diff --git a/InvenTree/stock/templates/stock/item_tests.html b/InvenTree/stock/templates/stock/item_tests.html index bd16ee8d29..77531714e0 100644 --- a/InvenTree/stock/templates/stock/item_tests.html +++ b/InvenTree/stock/templates/stock/item_tests.html @@ -7,7 +7,7 @@ {% include "stock/tabs.html" with tab='tests' %} -

{% trans "Test Results" %}

+

{% trans "Test Data" %}


@@ -20,8 +20,8 @@
- -
+ +
{% endblock %} @@ -30,11 +30,8 @@ loadStockTestResultsTable( $("#test-result-table"), { - params: { - stock_item: {{ item.id }}, - user_detail: true, - attachment_detail: true, - }, + part: {{ item.part.id }}, + stock_item: {{ item.id }}, } ); @@ -53,6 +50,21 @@ $("#add-test-result").click(function() { ); }); +$("#test-result-table").on('click', '.button-test-add', function() { + var button = $(this); + + var test_name = button.attr('pk'); + + launchModalForm( + "{% url 'stock-item-test-create' %}", { + data: { + stock_item: {{ item.id }}, + test: test_name + }, + } + ); +}); + $("#test-result-table").on('click', '.button-test-edit', function() { var button = $(this); diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 11c2065c35..29a57f5926 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -254,6 +254,8 @@ class StockItemTestResultCreate(AjaxCreateView): except (ValueError, StockItem.DoesNotExist): pass + initials['test'] = self.request.GET.get('test', '') + return initials def get_form(self): diff --git a/InvenTree/templates/js/stock.html b/InvenTree/templates/js/stock.html index af65782bef..963ccf525e 100644 --- a/InvenTree/templates/js/stock.html +++ b/InvenTree/templates/js/stock.html @@ -22,79 +22,98 @@ function removeStockRow(e) { function passFailBadge(result) { if (result) { - return `{% trans "PASS" %}`; + return `{% trans "PASS" %}`; } else { - return `{% trans "FAIL" %}`; + return `{% trans "FAIL" %}`; } } +function noResultBadge() { + return `{% trans "NO RESULT" %}`; +} + +function testKey(test_name) { + // Convert test name to a unique key without any illegal chars + + test_name = test_name.trim().toLowerCase(); + test_name = test_name.replace(' ', ''); + + test_name = test_name.replace(/[^0-9a-z]/gi, ''); + + return test_name; +} + function loadStockTestResultsTable(table, options) { /* * Load StockItemTestResult table */ - var params = options.params || {}; - - // HTML element to setup the filtering - var filterListElement = options.filterList || '#filter-list-stocktests'; + function formatDate(row) { + // Function for formatting date field + var html = row.date; - var filters = {}; + if (row.user_detail) { + html += `${row.user_detail.username}`; + } - filters = loadTableFilters("stocktests"); + if (row.attachment_detail) { + html += ``; + } - var original = {}; - - for (var key in params) { - original[key] = params[key]; + return html; } - setupFilterList("stocktests", table, filterListElement); + function makeButtons(row, grouped) { + var html = `
`; - // Override the default values, or add new ones - for (var key in params) { - filters[key] = params[key]; + html += makeIconButton('fa-plus icon-green', 'button-test-add', row.test_name, '{% trans "Add test result" %}'); + + if (!grouped) { + var pk = row.pk; + html += makeIconButton('fa-edit icon-blue', 'button-test-edit', pk, '{% trans "Edit test result" %}'); + html += makeIconButton('fa-trash-alt icon-red', 'button-test-delete', pk, '{% trans "Delete test result" %}'); + } + + html += "
"; + + return html; } + // First, load all the test templates table.inventreeTable({ + url: "{% url 'api-part-test-template-list' %}", method: 'get', formatNoMatches: function() { - return '{% trans "No test results matching query" %}'; + return "{% trans 'No test results found' %}"; + }, + queryParams: { + part: options.part, }, - url: "{% url 'api-stock-test-result-list' %}", - queryParams: filters, - original: original, columns: [ { field: 'pk', title: 'ID', - visible: false + visible: false, }, { - field: 'test', - title: '{% trans "Test" %}', + field: 'test_name', + title: "{% trans "Test Name" %}", sortable: true, formatter: function(value, row) { var html = value; - if (row.attachment_detail) { - html += ``; + if (row.result == null) { + html += noResultBadge(); + } else { + html += passFailBadge(row.result); } return html; - }, - }, - { - field: 'result', - title: "{% trans "Result" %}", - sortable: true, - formatter: function(value) { - return passFailBadge(value); } }, { field: 'value', - title: "{% trans "Value" %}", - sortable: true, + title: '{% trans "Value" %}', }, { field: 'notes', @@ -102,35 +121,85 @@ function loadStockTestResultsTable(table, options) { }, { field: 'date', - title: '{% trans "Uploaded" %}', - sortable: true, + title: '{% trans "Test Date" %}', formatter: function(value, row) { - var html = value; - - if (row.user_detail) { - html += `${row.user_detail.username}`; - } - - return html; + return formatDate(row); } }, { field: 'buttons', formatter: function(value, row) { - - var pk = row.pk; - - var html = `
`; - - html += makeIconButton('fa-edit icon-blue', 'button-test-edit', pk, '{% trans "Edit test result" %}'); - html += makeIconButton('fa-trash-alt icon-red', 'button-test-delete', pk, '{% trans "Delete test result" %}'); - - html += `
`; - - return html; + return makeButtons(row, false); } }, - ] + ], + groupBy: true, + groupByField: 'test_name', + groupByFormatter: function(field, id, data) { + + // Extract the "latest" row (data are returned in date order from the server) + var latest = data[data.length-1]; + + switch (field) { + case 'test_name': + return latest.test_name + ` (${data.length})` + passFailBadge(latest.result); + case 'value': + return latest.value; + case 'notes': + return latest.notes; + case 'date': + return formatDate(latest); + case 'buttons': + // Buttons are done differently for grouped rows + return makeButtons(latest, true); + default: + return "---"; + } + }, + onLoadSuccess: function(tableData) { + // Once the test template data are loaded, query for results + inventreeGet( + "{% url 'api-stock-test-result-list' %}", + { + stock_item: options.stock_item, + user_detail: true, + attachment_detail: true, + }, + { + success: function(data) { + + // Iterate through the returned test result data, and group by test + data.forEach(function(item) { + var match = false; + + var key = testKey(item.test); + + // Try to associate this result with a test row + tableData.forEach(function(row) { + // The result matches the test template row + if (key == testKey(row.test_name)) { + + // Force the names to be the same! + item.test_name = row.test_name; + match = true; + } + }); + + // No match could be found (this is a new test!) + if (!match) { + + item.test_name = item.test; + } + + tableData.push(item); + }); + + // Finally, push the data back into the table! + table.bootstrapTable("load", tableData); + } + }, + ); + } }); } From 5f318799c14b0fa5edc3835dd812d1e0392dbada Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 17 May 2020 21:51:46 +1000 Subject: [PATCH 15/18] Logic fix for table row grouping --- .../stock/templates/stock/item_tests.html | 1 + InvenTree/templates/js/stock.html | 23 ++++++++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/InvenTree/stock/templates/stock/item_tests.html b/InvenTree/stock/templates/stock/item_tests.html index 77531714e0..51c0bd8ed6 100644 --- a/InvenTree/stock/templates/stock/item_tests.html +++ b/InvenTree/stock/templates/stock/item_tests.html @@ -61,6 +61,7 @@ $("#test-result-table").on('click', '.button-test-add', function() { stock_item: {{ item.id }}, test: test_name }, + success: reloadTable, } ); }); diff --git a/InvenTree/templates/js/stock.html b/InvenTree/templates/js/stock.html index 963ccf525e..f0bcd47a3e 100644 --- a/InvenTree/templates/js/stock.html +++ b/InvenTree/templates/js/stock.html @@ -102,6 +102,10 @@ function loadStockTestResultsTable(table, options) { formatter: function(value, row) { var html = value; + if (row.required) { + html = `${value}`; + } + if (row.result == null) { html += noResultBadge(); } else { @@ -171,16 +175,27 @@ function loadStockTestResultsTable(table, options) { // Iterate through the returned test result data, and group by test data.forEach(function(item) { var match = false; + var override = false; var key = testKey(item.test); // Try to associate this result with a test row - tableData.forEach(function(row) { + tableData.forEach(function(row, index) { + + // The result matches the test template row if (key == testKey(row.test_name)) { - + // Force the names to be the same! item.test_name = row.test_name; + item.required = row.required; + + if (row.result == null) { + // The original row has not recorded a result - override! + tableData[index] = item; + override = true; + } + match = true; } }); @@ -191,7 +206,9 @@ function loadStockTestResultsTable(table, options) { item.test_name = item.test; } - tableData.push(item); + if (!override) { + tableData.push(item); + } }); // Finally, push the data back into the table! From 6cb017bbfd7c206e6177de3a0223d5d924506239 Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 17 May 2020 22:03:55 +1000 Subject: [PATCH 16/18] Add functions to test if a stock item has passed all tests --- InvenTree/part/models.py | 4 +++ InvenTree/stock/fixtures/stock_tests.yaml | 39 +++++++++++++++++++- InvenTree/stock/models.py | 43 +++++++++++++++++++++++ InvenTree/stock/tests.py | 32 +++++++++++++++++ 4 files changed, 117 insertions(+), 1 deletion(-) diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 81b8206469..d37d666be4 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -1009,6 +1009,10 @@ class Part(MPTTModel): tests = tests.filter(required=required) return tests + + def getRequiredTests(self): + # Return the tests which are required by this part + return self.getTestTemplates(required=True) @property def attachment_count(self): diff --git a/InvenTree/stock/fixtures/stock_tests.yaml b/InvenTree/stock/fixtures/stock_tests.yaml index 9a82b6ada7..8207ccaa8c 100644 --- a/InvenTree/stock/fixtures/stock_tests.yaml +++ b/InvenTree/stock/fixtures/stock_tests.yaml @@ -28,4 +28,41 @@ test: "Temperature Test" result: True date: 2020-05-17 - notes: 'Passed temperature test by making it cooler' \ No newline at end of file + notes: 'Passed temperature test by making it cooler' + +- model: stock.stockitemtestresult + fields: + stock_item: 522 + test: 'applypaint' + result: True + date: 2020-05-17 + +- model: stock.stockitemtestresult + fields: + stock_item: 522 + test: 'applypaint' + result: False + date: 2020-05-18 + +- model: stock.stockitemtestresult + fields: + stock_item: 522 + test: 'Attach Legs' + result: True + date: 2020-05-17 + +- model: stock.stockitemtestresult + fields: + stock_item: 522 + test: 'Check that chair is GreEn ' + result: True + date: 2020-05-17 + +- model: stock.stockitemtestresult + pk: 12345 + fields: + stock_item: 522 + test: 'test strength of chair' + result: False + value: 100kg + date: 2020-05-17 \ No newline at end of file diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 851c80a9ed..4df61b0ee3 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -967,6 +967,49 @@ class StockItem(MPTTModel): return result_map + def requiredTestStatus(self): + """ + Return the status of the tests required for this StockItem. + + return: + A dict containing the following items: + - total: Number of required tests + - passed: Number of tests that have passed + - failed: Number of tests that have failed + """ + + # All the tests required by the part object + required = self.part.getRequiredTests() + + results = self.testResultMap() + + total = len(required) + passed = 0 + failed = 0 + + for test in required: + key = helpers.generateTestKey(test.test_name) + + if key in results: + result = results[key] + + if result.result: + passed += 1 + else: + failed += 1 + + return { + 'total': total, + 'passed': passed, + 'failed': failed, + } + + def passedAllRequiredTests(self): + + status = self.requiredTestStatus() + + return status['passed'] >= status['total'] + @receiver(pre_delete, sender=StockItem, dispatch_uid='stock_item_pre_delete_log') def before_delete_stock_item(sender, instance, using, **kwargs): diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index cd927e0251..da46fee064 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -4,6 +4,7 @@ from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from .models import StockLocation, StockItem, StockItemTracking +from .models import StockItemTestResult from part.models import Part @@ -15,6 +16,7 @@ class StockTest(TestCase): fixtures = [ 'category', 'part', + 'test_templates', 'location', 'stock', 'stock_tests', @@ -432,3 +434,33 @@ class TestResultTest(StockTest): # Keys are all lower-case and do not contain spaces for test in ['firmwareversion', 'settingschecksum', 'temperaturetest']: self.assertIn(test, result_map.keys()) + + def test_test_results(self): + item = StockItem.objects.get(pk=522) + + status = item.requiredTestStatus() + + self.assertEqual(status['total'], 5) + self.assertEqual(status['passed'], 3) + self.assertEqual(status['failed'], 1) + + self.assertFalse(item.passedAllRequiredTests()) + + # Add some new test results to make it pass! + test = StockItemTestResult.objects.get(pk=12345) + test.result = True + test.save() + + StockItemTestResult.objects.create( + stock_item=item, + test='sew cushion', + result=True + ) + + results = item.testResultMap() + + for key in results.keys(): + result = results[key] + + self.assertTrue(item.passedAllRequiredTests()) + From 02b0c0831d56b8aa526978e85a9f6b3dc2ec7a1e Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 17 May 2020 22:29:07 +1000 Subject: [PATCH 17/18] Removed test that caused a bug --- InvenTree/stock/models.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index 4df61b0ee3..ad2382c3e6 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -276,10 +276,6 @@ class StockItem(MPTTModel): # Serial numbered items cannot be deleted on depletion self.delete_on_deplete = False - # A template part cannot be instantiated as a StockItem - if self.part.is_template: - raise ValidationError({'part': _('Stock item cannot be created for a template Part')}) - except PartModels.Part.DoesNotExist: # This gets thrown if self.supplier_part is null # TODO - Find a test than can be perfomed... From 1cc09778168c8cc8a6ac4c5bf4f2f6f916408d0b Mon Sep 17 00:00:00 2001 From: Oliver Walters Date: Sun, 17 May 2020 22:33:41 +1000 Subject: [PATCH 18/18] Display testing status for a stock item --- InvenTree/part/views.py | 1 - InvenTree/stock/models.py | 3 +++ InvenTree/stock/templates/stock/item_base.html | 13 +++++++++++++ InvenTree/stock/templates/stock/item_tests.html | 3 ++- InvenTree/stock/tests.py | 6 ------ InvenTree/templates/js/stock.html | 2 +- 6 files changed, 19 insertions(+), 9 deletions(-) diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index c81df31484..eda1db923b 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -301,7 +301,6 @@ class MakePartVariant(AjaxCreateView): form = super(AjaxCreateView, self).get_form() # Hide some variant-related fields - form.fields['is_template'].widget = HiddenInput() form.fields['variant_of'].widget = HiddenInput() return form diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py index ad2382c3e6..c50a58bea9 100644 --- a/InvenTree/stock/models.py +++ b/InvenTree/stock/models.py @@ -1000,6 +1000,9 @@ class StockItem(MPTTModel): 'failed': failed, } + def hasRequiredTests(self): + return self.part.getRequiredTests().count() > 0 + def passedAllRequiredTests(self): status = self.requiredTestStatus() diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index 4e2d6b7439..782e51a2b8 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -15,6 +15,12 @@ InvenTree | {% trans "Stock Item" %} - {{ item }} {% block pre_content %} {% include 'stock/loc_link.html' with location=item.location %} +{% if item.hasRequiredTests and not item.passedAllRequiredTests %} +
+ {% trans "This stock item has not passed all required tests" %} +
+{% endif %} + {% for allocation in item.sales_order_allocations.all %}
{% trans "This stock item is allocated to Sales Order" %} #{{ allocation.line.order.reference }} ({% trans "Quantity" %}: {% decimal allocation.quantity %}) @@ -221,6 +227,13 @@ InvenTree | {% trans "Stock Item" %} - {{ item }} {% trans "Status" %} {% stock_status_label item.status %} + {% if item.hasRequiredTests %} + + + {% trans "Tests" %} + {{ item.requiredTestStatus.passed }} / {{ item.requiredTestStatus.total }} + + {% endif %} {% endblock %} diff --git a/InvenTree/stock/templates/stock/item_tests.html b/InvenTree/stock/templates/stock/item_tests.html index 51c0bd8ed6..6a69d55eb9 100644 --- a/InvenTree/stock/templates/stock/item_tests.html +++ b/InvenTree/stock/templates/stock/item_tests.html @@ -36,7 +36,8 @@ loadStockTestResultsTable( ); function reloadTable() { - $("#test-result-table").bootstrapTable("refresh"); + location.reload(); + //$("#test-result-table").bootstrapTable("refresh"); } $("#add-test-result").click(function() { diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index da46fee064..045cbe164e 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -456,11 +456,5 @@ class TestResultTest(StockTest): test='sew cushion', result=True ) - - results = item.testResultMap() - - for key in results.keys(): - result = results[key] self.assertTrue(item.passedAllRequiredTests()) - diff --git a/InvenTree/templates/js/stock.html b/InvenTree/templates/js/stock.html index f0bcd47a3e..49c3151ff0 100644 --- a/InvenTree/templates/js/stock.html +++ b/InvenTree/templates/js/stock.html @@ -68,7 +68,7 @@ function loadStockTestResultsTable(table, options) { html += makeIconButton('fa-plus icon-green', 'button-test-add', row.test_name, '{% trans "Add test result" %}'); - if (!grouped) { + if (!grouped && row.result != null) { var pk = row.pk; html += makeIconButton('fa-edit icon-blue', 'button-test-edit', pk, '{% trans "Edit test result" %}'); html += makeIconButton('fa-trash-alt icon-red', 'button-test-delete', pk, '{% trans "Delete test result" %}');