diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py index 8c5d59181d..9880662e63 100644 --- a/InvenTree/InvenTree/helpers.py +++ b/InvenTree/InvenTree/helpers.py @@ -19,6 +19,23 @@ 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 illegal chars 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(" ", "") + + # Remove any characters that cannot be used to represent a variable + key = re.sub(r'[^a-zA-Z0-9]', '', key) + + return key + + def getMediaUrl(filename): """ Return the qualified access path for the given file, 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/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/api.py b/InvenTree/part/api.py index 84ff83af0a..1b672d4c68 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,52 @@ 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 + + # 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] + + filter_backends = [ + DjangoFilterBackend, + filters.OrderingFilter, + filters.SearchFilter, + ] + + class PartThumbs(generics.ListAPIView): """ API endpoint for retrieving information on available Part thumbnails """ @@ -635,8 +681,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/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/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/forms.py b/InvenTree/part/forms.py index 4d70ab927a..a276d62c54 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. @@ -125,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/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/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 7639b9c25b..d37d666be4 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -990,6 +990,30 @@ 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 + + def getRequiredTests(self): + # Return the tests which are required by this part + return self.getTestTemplates(required=True) + @property def attachment_count(self): """ Count the number of attachments for this part. @@ -1109,6 +1133,88 @@ 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, *args, **kwargs): + + 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. + """ + + 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( + 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") + }) + + super().validate_unique(exclude) + + @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', + limit_choices_to={'trackable': True}, + ) + + 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 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. 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 @@