Merge pull request #812 from SchrodingersGat/more-testing

Add function to generate "keys" for test results.
This commit is contained in:
Oliver 2020-05-17 22:38:19 +10:00 committed by GitHub
commit 915bbef3b4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 985 additions and 88 deletions

View File

@ -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,

View File

@ -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'))

View File

@ -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)

View File

@ -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'),
])),

View File

@ -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

View File

@ -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

View File

@ -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',
]

View File

@ -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')),
],
),
]

View File

@ -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'),
),
]

View File

@ -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

View File

@ -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.

View File

@ -128,6 +128,15 @@
<td><i>{% trans "Part is not a virtual part" %}</i></td>
{% endif %}
</tr>
<tr>
<td><b>{% trans "Template" %}</b></td>
<td>{% include "slide.html" with state=part.is_template field='is_template' %}</td>
{% if part.is_template %}
<td>{% trans "Part is a template part (variants can be made from this part)" %}</td>
{% else %}
<td><i>{% trans "Part is not a template part" %}</i></td>
{% endif %}
</tr>
<tr>
<td><b>{% trans "Assembly" %}</b></td>
<td>{% include "slide.html" with state=part.assembly field='assembly' %}</td>
@ -173,6 +182,15 @@
<td><i>{% trans "Part cannot be sold to customers" %}</i></td>
{% endif %}
</tr>
<tr>
<td><b>{% trans "Active" %}</b></td>
<td>{% include "slide.html" with state=part.active field='active' %}</td>
{% if part.active %}
<td>{% trans "Part is active" %}</td>
{% else %}
<td><i>{% trans "Part is not active" %}</i></td>
{% endif %}
</tr>
</table>
</div>
</div>
@ -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',

View File

@ -0,0 +1,75 @@
{% extends "part/part_base.html" %}
{% load static %}
{% load i18n %}
{% block details %}
{% include 'part/tabs.html' with tab='tests' %}
<h4>{% trans "Part Test Templates" %}</h4>
<hr>
<div id='button-toolbar'>
<div class='button-toolbar container-fluid' style="float: right;">
<div class='btn-group' role='group'>
<button type='button' class='btn btn-success' id='add-test-template'>{% trans "Add Test Template" %}</button>
</div>
<div class='filter-list' id='filter-list-parttests'>
<!-- Empty div -->
</div>
</div>
</div>
<table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='test-template-table'></table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
loadPartTestTemplateTable(
$("#test-template-table"),
{
part: {{ part.pk }},
params: {
part: {{ part.pk }},
}
}
);
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 %}

View File

@ -48,7 +48,9 @@
<a href="{% url 'part-sales-orders' part.id %}">{% trans "Sales Orders" %} <span class='badge'>{{ part.sales_orders|length }}</span></a>
</li>
{% endif %}
{% if 0 and part.trackable %}
{% if part.trackable %}
{% if 0 %}
<!-- TODO - Add the 'tracking' tab back in -->
<li{% ifequal tab 'track' %} class="active"{% endifequal %}>
<a href="{% url 'part-track' part.id %}">{% trans "Tracking" %}
{% if parts.serials.all|length > 0 %}
@ -56,6 +58,12 @@
{% endif %}
</a></li>
{% endif %}
<li{% ifequal tab 'tests' %} class='active'{% endifequal %}>
<a href='{% url "part-test-templates" part.id %}'>{% trans "Tests" %}
{% if part.getTestTemplates.count > 0 %}<span class='badge'>{{ part.getTestTemplates.count }}</span>{% endif %}
</a>
</li>
{% endif %}
<li{% ifequal tab 'attachments' %} class="active"{% endifequal %}>
<a href="{% url 'part-attachments' part.id %}">{% trans "Attachments" %} {% if part.attachment_count > 0 %}<span class="badge">{{ part.attachment_count }}</span>{% endif %}</a>
</li>

View File

@ -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)

View File

@ -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())

View File

@ -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)

View File

@ -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'),
@ -107,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<pk>\d+)/edit/', views.PartTestTemplateEdit.as_view(), name='part-test-template-edit'),
url(r'^(?P<pk>\d+)/delete/', views.PartTestTemplateDelete.as_view(), name='part-test-template-delete'),
])),
# Part parameters
url(r'^parameter/', include(part_parameter_urls)),

View File

@ -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 """
@ -251,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

View File

@ -29,3 +29,40 @@
result: True
date: 2020-05-17
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

View File

@ -46,6 +46,7 @@ class EditStockItemTestResultForm(HelperForm):
'test',
'result',
'value',
'attachment',
'notes',
]

View File

@ -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...
@ -962,10 +958,57 @@ 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
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 hasRequiredTests(self):
return self.part.getRequiredTests().count() > 0
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):

View File

@ -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 %}
<div class='alert alert-block alert-danger'>
{% trans "This stock item has not passed all required tests" %}
</div>
{% endif %}
{% for allocation in item.sales_order_allocations.all %}
<div class='alert alert-block alert-info'>
{% trans "This stock item is allocated to Sales Order" %} <a href="{% url 'so-detail' allocation.line.order.id %}"><b>#{{ allocation.line.order.reference }}</b></a> ({% trans "Quantity" %}: {% decimal allocation.quantity %})
@ -221,6 +227,13 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
<td>{% trans "Status" %}</td>
<td>{% stock_status_label item.status %}</td>
</tr>
{% if item.hasRequiredTests %}
<tr>
<td><span class='fas fa-vial'></span></td>
<td>{% trans "Tests" %}</td>
<td>{{ item.requiredTestStatus.passed }} / {{ item.requiredTestStatus.total }}</td>
</tr>
{% endif %}
</table>
{% endblock %}

View File

@ -7,13 +7,13 @@
{% include "stock/tabs.html" with tab='tests' %}
<h4>{% trans "Test Results" %}</h4>
<h4>{% trans "Test Data" %}</h4>
<hr>
<div id='button-toolbar'>
<div class='button-toolbar container-fluid' style="float: right;">
<div class='btn-group' role='group'>
<button type='button' class='btn btn-success' id='add-test-result'>{% trans "Add Test Result" %}</button>
<button type='button' class='btn btn-success' id='add-test-result'>{% trans "Add Test Data" %}</button>
</div>
<div class='filter-list' id='filter-list-stocktests'>
<!-- Empty div -->
@ -30,16 +30,14 @@
loadStockTestResultsTable(
$("#test-result-table"), {
params: {
part: {{ item.part.id }},
stock_item: {{ item.id }},
user_detail: true,
attachment_detail: true,
},
}
);
function reloadTable() {
$("#test-result-table").bootstrapTable("refresh");
location.reload();
//$("#test-result-table").bootstrapTable("refresh");
}
$("#add-test-result").click(function() {
@ -53,6 +51,22 @@ $("#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
},
success: reloadTable,
}
);
});
$("#test-result-table").on('click', '.button-test-edit', function() {
var button = $(this);

View File

@ -10,7 +10,7 @@
{% if item.part.trackable %}
<li{% if tab == 'tests' %} class='active'{% endif %}>
<a href="{% url 'stock-item-test-results' item.id %}">
{% trans "Test Results" %}
{% trans "Test Data" %}
{% if item.test_results.count > 0 %}<span class='badge'>{{ item.test_results.count }}</span>{% endif %}
</a>
</li>

View File

@ -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',
@ -429,5 +431,30 @@ 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())
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
)
self.assertTrue(item.passedAllRequiredTests())

View File

@ -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):
@ -261,6 +263,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
@ -279,6 +292,8 @@ class StockItemTestResultEdit(AjaxUpdateView):
form.fields['stock_item'].widget = HiddenInput()
form.fields['attachment'].queryset = self.object.stock_item.attachments.all()
return form

View File

@ -285,3 +285,84 @@ function loadPartTable(table, url, options={}) {
location.href = '/part/export/?parts=' + parts;
});
}
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 `<span class='label label-green'>{% trans "YES" %}</span>`;
} else {
return `<span class='label label-yellow'>{% trans "NO" %}</span>`;
}
}
},
{
field: 'buttons',
formatter: function(value, row) {
var pk = row.pk;
if (row.part == part) {
var html = `<div class='btn-group float-right' role='group'>`;
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 += `</div>`;
return html;
} else {
return '{% trans "This test is defined for a parent part" %}';
}
}
}
]
});
}

View File

@ -22,79 +22,102 @@ function removeStockRow(e) {
function passFailBadge(result) {
if (result) {
return `<span class='label label-green'>{% trans "PASS" %}</span>`;
return `<span class='label label-green float-right'>{% trans "PASS" %}</span>`;
} else {
return `<span class='label label-red'>{% trans "FAIL" %}</span>`;
return `<span class='label label-red float-right'>{% trans "FAIL" %}</span>`;
}
}
function noResultBadge() {
return `<span class='label label-blue float-right'>{% trans "NO RESULT" %}</span>`;
}
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 || {};
function formatDate(row) {
// Function for formatting date field
var html = row.date;
// HTML element to setup the filtering
var filterListElement = options.filterList || '#filter-list-stocktests';
var filters = {};
filters = loadTableFilters("stocktests");
var original = {};
for (var key in params) {
original[key] = params[key];
if (row.user_detail) {
html += `<span class='badge'>${row.user_detail.username}</span>`;
}
setupFilterList("stocktests", 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 results matching query" %}';
},
url: "{% url 'api-stock-test-result-list' %}",
queryParams: filters,
original: original,
columns: [
{
field: 'pk',
title: 'ID',
visible: false
},
{
field: 'test',
title: '{% trans "Test" %}',
sortable: true,
formatter: function(value, row) {
var html = value;
if (row.attachment_detail) {
html += `<a href='${row.attachment_detail.attachment}'><span class='fas fa-file-alt label-right'></span></a>`;
}
return html;
}
function makeButtons(row, grouped) {
var html = `<div class='btn-group float-right' role='group'>`;
html += makeIconButton('fa-plus icon-green', 'button-test-add', row.test_name, '{% trans "Add test result" %}');
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" %}');
}
html += "</div>";
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 found' %}";
},
queryParams: {
part: options.part,
},
columns: [
{
field: 'pk',
title: 'ID',
visible: false,
},
{
field: 'result',
title: "{% trans "Result" %}",
field: 'test_name',
title: "{% trans "Test Name" %}",
sortable: true,
formatter: function(value) {
return passFailBadge(value);
formatter: function(value, row) {
var html = value;
if (row.required) {
html = `<b>${value}</b>`;
}
if (row.result == null) {
html += noResultBadge();
} else {
html += passFailBadge(row.result);
}
return html;
}
},
{
field: 'value',
title: "{% trans "Value" %}",
sortable: true,
title: '{% trans "Value" %}',
},
{
field: 'notes',
@ -102,35 +125,98 @@ 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 += `<span class='badge'>${row.user_detail.username}</span>`;
}
return html;
return formatDate(row);
}
},
{
field: 'buttons',
formatter: function(value, row) {
var pk = row.pk;
var html = `<div class='btn-group float-right' role='group'>`;
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 += `</div>`;
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 + ` <i>(${data.length})</i>` + 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 override = false;
var key = testKey(item.test);
// Try to associate this result with a test 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;
}
});
// No match could be found (this is a new test!)
if (!match) {
item.test_name = item.test;
}
if (!override) {
tableData.push(item);
}
});
// Finally, push the data back into the table!
table.bootstrapTable("load", tableData);
}
},
);
}
});
}

View File

@ -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