2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-18 13:05:42 +00:00

[WIP] Test result table (#6430)

* Add basic table for stock item test results

* Improve custom data formatter callback

* Custom data formatter for returned results

* Update YesNoButton functionality

- Add PassFailButton with custom text

* Enhancements for stock item test result table

- Render all data

* Add placeholder row actions

* Fix table link

* Add option to filter parttesttemplate table by "inherited"

* Navigate through to parent part

* Update PartTestTemplate model

- Save 'key' value to database
- Update whenever model is saved
- Custom data migration

* Custom migration step in tasks.py

- Add custom management command
- Wraps migration step in maintenance mode

* Improve uniqueness validation for PartTestTemplate

* Add 'template' field to StockItemTestResult

- Links to a PartTestTemplate instance
- Add migrations to link existing PartTestTemplates

* Add "results" count to PartTestTemplate API

- Include in rendered tables

* Add 'results' column to test result table

- Allow filtering too

* Update serializer for StockItemTestResult

- Include template information
- Update CUI and PUI tables

* Control template_detail field with query params

* Update ref in api_version.py

* Update data migration

- Ensure new template is created for top level assembly

* Fix admin integration

* Update StockItemTestResult table

- Remove 'test' field
- Make 'template' field non-nullable
- Previous data migrations should have accounted for this

* Implement "legacy" API support

- Create test result by providing test name
- Lookup existing template

* PUI: Cleanup table

* Update tasks.py

- Exclude temporary settings when exporting data

* Fix unique validation check

* Remove duplicate code

* CUI: Fix data rendering

* More refactoring of PUI table

* More fixes for PUI table

* Get row expansion working (kinda)

* Improve rendering of subtable

* More PUI updates:

- Edit existing results
- Add new results

* allow delete of test result

* Fix typo

* Updates for admin integration

* Unit tests for stock migrations

* Added migration test for PartTestTemplate

* Fix for AttachmentTable

- Rebuild actions when permissions are recalculated

* Update test fixtures

* Add ModelType information

* Fix TableState

* Fix dataFormatter type def

* Improve table rendering

* Correctly filter "edit" and "delete" buttons

* Loosen requirements for dataFormatter

* Fixtures for report tests

* Better API filtering for StocokItemTestResult list

- Add Filter class
- Add option for filtering against legacy "name" data

* Cleanup API filter

* Fix unit tests

* Further unit test fixes

* Include test results for installed stock items

* Improve rendering of test result table

* Fix filtering for getTestResults

* More unit test fixes

* Fix more unit tests

* FIx part unit test

* More fixes

* More unit test fixes

* Rebuild stock item trees when merging

* Helper function for adding a test result to a stock item

* Set init fix

* Code cleanup

* Cleanup unused variables

* Add docs and more unit tests

* Update build unit test
This commit is contained in:
Oliver
2024-02-18 23:26:01 +11:00
committed by GitHub
parent ad1c1ae604
commit 0f51127adf
50 changed files with 1505 additions and 243 deletions

View File

@ -1,12 +1,17 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 168
INVENTREE_API_VERSION = 169
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v168 -> 2024-02-07 : https://github.com/inventree/InvenTree/pull/4824
v169 -> 2024-02-14 : https://github.com/inventree/InvenTree/pull/6430
- Adds 'key' field to PartTestTemplate API endpoint
- Adds annotated 'results' field to PartTestTemplate API endpoint
- Adds 'template' field to StockItemTestResult API endpoint
v168 -> 2024-02-14 : https://github.com/inventree/InvenTree/pull/4824
- Adds machine CRUD API endpoints
- Adds machine settings API endpoints
- Adds machine restart API endpoint

View File

@ -76,16 +76,23 @@ def extract_int(reference, clip=0x7FFFFFFF, allow_negative=False):
return ref_int
def generateTestKey(test_name):
def generateTestKey(test_name: str) -> str:
"""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.
"""
if test_name is None:
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)
key = re.sub(r'[^a-zA-Z0-9_]', '', key)
# If the key starts with a digit, prefix with an underscore
if key[0].isdigit():
key = '_' + key
return key

View File

@ -0,0 +1,19 @@
"""Check if there are any pending database migrations, and run them."""
import logging
from django.core.management.base import BaseCommand
from InvenTree.tasks import check_for_migrations
logger = logging.getLogger('inventree')
class Command(BaseCommand):
"""Check if there are any pending database migrations, and run them."""
def handle(self, *args, **kwargs):
"""Check for any pending database migrations."""
logger.info('Checking for pending database migrations')
check_for_migrations(force=True, reload_registry=False)
logger.info('Database migrations complete')

View File

@ -509,6 +509,20 @@ class TestHelpers(TestCase):
self.assertNotIn(PartCategory, models)
self.assertNotIn(InvenTreeSetting, models)
def test_test_key(self):
"""Test for the generateTestKey function."""
tests = {
' Hello World ': 'helloworld',
' MY NEW TEST KEY ': 'mynewtestkey',
' 1234 5678': '_12345678',
' 100 percenT': '_100percent',
' MY_NEW_TEST': 'my_new_test',
' 100_new_tests': '_100_new_tests',
}
for name, key in tests.items():
self.assertEqual(helpers.generateTestKey(name), key)
class TestQuoteWrap(TestCase):
"""Tests for string wrapping."""

View File

@ -655,9 +655,10 @@ class BuildTest(BuildTestBase):
# let's complete the required test and see if it could be saved
StockItemTestResult.objects.create(
stock_item=self.stockitem_with_required_test,
test=self.test_template_required.test_name,
template=self.test_template_required,
result=True
)
self.build_w_tests_trackable.complete_build_output(self.stockitem_with_required_test, None)
# let's see if a non required test could be saved

View File

@ -363,6 +363,7 @@ class PartTestTemplateAdmin(admin.ModelAdmin):
"""Admin class for the PartTestTemplate model."""
list_display = ('part', 'test_name', 'required')
readonly_fields = ['key']
autocomplete_fields = ('part',)

View File

@ -389,29 +389,53 @@ class PartTestTemplateFilter(rest_filters.FilterSet):
Note that for the 'part' field, we also include any parts "above" the specified part.
"""
variants = part.get_ancestors(include_self=True)
return queryset.filter(part__in=variants)
include_inherited = str2bool(
self.request.query_params.get('include_inherited', True)
)
if include_inherited:
return queryset.filter(part__in=part.get_ancestors(include_self=True))
else:
return queryset.filter(part=part)
class PartTestTemplateDetail(RetrieveUpdateDestroyAPI):
class PartTestTemplateMixin:
"""Mixin class for the PartTestTemplate API endpoints."""
queryset = PartTestTemplate.objects.all()
serializer_class = part_serializers.PartTestTemplateSerializer
def get_queryset(self, *args, **kwargs):
"""Return an annotated queryset for the PartTestTemplateDetail endpoints."""
queryset = super().get_queryset(*args, **kwargs)
queryset = part_serializers.PartTestTemplateSerializer.annotate_queryset(
queryset
)
return queryset
class PartTestTemplateDetail(PartTestTemplateMixin, RetrieveUpdateDestroyAPI):
"""Detail endpoint for PartTestTemplate model."""
queryset = PartTestTemplate.objects.all()
serializer_class = part_serializers.PartTestTemplateSerializer
pass
class PartTestTemplateList(ListCreateAPI):
class PartTestTemplateList(PartTestTemplateMixin, ListCreateAPI):
"""API endpoint for listing (and creating) a PartTestTemplate."""
queryset = PartTestTemplate.objects.all()
serializer_class = part_serializers.PartTestTemplateSerializer
filterset_class = PartTestTemplateFilter
filter_backends = SEARCH_ORDER_FILTER
search_fields = ['test_name', 'description']
ordering_fields = ['test_name', 'required', 'requires_value', 'requires_attachment']
ordering_fields = [
'test_name',
'required',
'requires_value',
'requires_attachment',
'results',
]
ordering = 'test_name'

View File

@ -4,30 +4,35 @@
fields:
part: 10000
test_name: Test strength of chair
key: 'teststrengthofchair'
- model: part.parttesttemplate
pk: 2
fields:
part: 10000
test_name: Apply paint
key: 'applypaint'
- model: part.parttesttemplate
pk: 3
fields:
part: 10000
test_name: Sew cushion
key: 'sewcushion'
- model: part.parttesttemplate
pk: 4
fields:
part: 10000
test_name: Attach legs
key: 'attachlegs'
- model: part.parttesttemplate
pk: 5
fields:
part: 10000
test_name: Record weight
key: 'recordweight'
required: false
# Add some tests for one of the variants
@ -35,12 +40,30 @@
pk: 6
fields:
part: 10003
test_name: Check that chair is green
test_name: Check chair is green
key: 'checkchairisgreen'
required: true
- model: part.parttesttemplate
pk: 7
pk: 8
fields:
part: 10004
test_name: Check that chair is especially green
part: 25
test_name: 'Temperature Test'
key: 'temperaturetest'
required: False
- model: part.parttesttemplate
pk: 9
fields:
part: 25
test_name: 'Settings Checksum'
key: 'settingschecksum'
required: False
- model: part.parttesttemplate
pk: 10
fields:
part: 25
test_name: 'Firmware Version'
key: 'firmwareversion'
required: False

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.9 on 2024-02-07 03:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0119_auto_20231120_0457'),
]
operations = [
migrations.AddField(
model_name='parttesttemplate',
name='key',
field=models.CharField(blank=True, help_text='Simplified key for the test', max_length=100, verbose_name='Test Key'),
),
]

View File

@ -0,0 +1,29 @@
# Generated by Django 4.2.9 on 2024-02-07 03:44
from django.db import migrations
def set_key(apps, schema_editor):
"""Create a 'key' value for existing PartTestTemplate objects."""
import InvenTree.helpers
PartTestTemplate = apps.get_model('part', 'PartTestTemplate')
for template in PartTestTemplate.objects.all():
template.key = InvenTree.helpers.generateTestKey(str(template.test_name).strip())
template.save()
if PartTestTemplate.objects.count() > 0:
print(f"\nUpdated 'key' value for {PartTestTemplate.objects.count()} PartTestTemplate objects")
class Migration(migrations.Migration):
dependencies = [
('part', '0120_parttesttemplate_key'),
]
operations = [
migrations.RunPython(set_key, reverse_code=migrations.RunPython.noop)
]

View File

@ -3393,6 +3393,10 @@ class PartTestTemplate(InvenTree.models.InvenTreeMetadataModel):
run on the model (refer to the validate_unique function).
"""
def __str__(self):
"""Format a string representation of this PartTestTemplate."""
return ' | '.join([self.part.name, self.test_name])
@staticmethod
def get_api_url():
"""Return the list API endpoint URL associated with the PartTestTemplate model."""
@ -3408,6 +3412,8 @@ class PartTestTemplate(InvenTree.models.InvenTreeMetadataModel):
"""Clean fields for the PartTestTemplate model."""
self.test_name = self.test_name.strip()
self.key = helpers.generateTestKey(self.test_name)
self.validate_unique()
super().clean()
@ -3418,30 +3424,18 @@ class PartTestTemplate(InvenTree.models.InvenTreeMetadataModel):
'part': _('Test templates can only be created for trackable parts')
})
# Get a list of all tests "above" this one
# Check that this test is unique within the part tree
tests = PartTestTemplate.objects.filter(
part__in=self.part.get_ancestors(include_self=True)
)
key=self.key, part__tree_id=self.part.tree_id
).exclude(pk=self.pk)
# 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')
})
if tests.exists():
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,
@ -3457,6 +3451,13 @@ class PartTestTemplate(InvenTree.models.InvenTreeMetadataModel):
help_text=_('Enter a name for the test'),
)
key = models.CharField(
blank=True,
max_length=100,
verbose_name=_('Test Key'),
help_text=_('Simplified key for the test'),
)
description = models.CharField(
blank=False,
null=True,

View File

@ -156,9 +156,20 @@ class PartTestTemplateSerializer(InvenTree.serializers.InvenTreeModelSerializer)
'required',
'requires_value',
'requires_attachment',
'results',
]
key = serializers.CharField(read_only=True)
results = serializers.IntegerField(
label=_('Results'),
help_text=_('Number of results recorded against this template'),
read_only=True,
)
@staticmethod
def annotate_queryset(queryset):
"""Custom query annotations for the PartTestTemplate serializer."""
return queryset.annotate(results=SubqueryCount('test_results'))
class PartSalePriceSerializer(InvenTree.serializers.InvenTreeModelSerializer):

View File

@ -806,14 +806,14 @@ class PartAPITest(PartAPITestBase):
response = self.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 7)
self.assertEqual(len(response.data), 9)
# Request for a particular part
response = self.get(url, data={'part': 10000})
self.assertEqual(len(response.data), 5)
response = self.get(url, data={'part': 10004})
self.assertEqual(len(response.data), 7)
self.assertEqual(len(response.data), 6)
# Try to post a new object (missing description)
response = self.post(

View File

@ -229,3 +229,47 @@ class TestPartParameterTemplateMigration(MigratorTestCase):
self.assertEqual(template.choices, '')
self.assertEqual(template.checkbox, False)
class TestPartTestParameterMigration(MigratorTestCase):
"""Unit tests for the PartTestTemplate model migrations."""
migrate_from = ('part', '0119_auto_20231120_0457')
migrate_to = ('part', '0121_auto_20240207_0344')
test_keys = {
'atest': 'A test',
'someresult': 'Some result',
'anotherresult': 'Another result',
}
def prepare(self):
"""Setup initial database state."""
Part = self.old_state.apps.get_model('part', 'part')
PartTestTemplate = self.old_state.apps.get_model('part', 'parttesttemplate')
# Create a part
p = Part.objects.create(
name='Test Part',
description='A test part',
level=0,
lft=0,
rght=0,
tree_id=0,
)
# Create some test templates
for v in self.test_keys.values():
PartTestTemplate.objects.create(
test_name=v, part=p, description='A test template'
)
self.assertEqual(PartTestTemplate.objects.count(), 3)
def test_key_field(self):
"""Self that the key field is created and correctly filled."""
PartTestTemplate = self.new_state.apps.get_model('part', 'parttesttemplate')
for key, value in self.test_keys.items():
template = PartTestTemplate.objects.get(test_name=value)
self.assertEqual(template.key, key)

View File

@ -378,8 +378,8 @@ class TestTemplateTest(TestCase):
# 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().count(), 6)
self.assertEqual(variant.getTestTemplates(include_parent=False).count(), 0)
self.assertEqual(variant.getTestTemplates(required=True).count(), 5)
def test_uniqueness(self):
@ -389,21 +389,29 @@ class TestTemplateTest(TestCase):
with self.assertRaises(ValidationError):
PartTestTemplate.objects.create(part=variant, test_name='Record weight')
# Test that error is raised if we try to create a duplicate test name
with self.assertRaises(ValidationError):
PartTestTemplate.objects.create(
part=variant, test_name='Check that chair is especially green'
part=variant, test_name='Check chair is green'
)
# Also should fail if we attempt to create a test that would generate the same key
with self.assertRaises(ValidationError):
PartTestTemplate.objects.create(
template = PartTestTemplate.objects.create(
part=variant, test_name='ReCoRD weiGHT '
)
template.clean()
# But we should be able to create a new one!
n = variant.getTestTemplates().count()
PartTestTemplate.objects.create(part=variant, test_name='A Sample Test')
template = PartTestTemplate.objects.create(
part=variant, test_name='A Sample Test'
)
# Test key should have been saved
self.assertEqual(template.key, 'asampletest')
self.assertEqual(variant.getTestTemplates().count(), n + 1)

View File

@ -194,6 +194,7 @@ class ReportTest(InvenTreeAPITestCase):
'part',
'company',
'location',
'test_templates',
'supplier_part',
'stock',
'stock_tests',

View File

@ -317,6 +317,6 @@ class StockTrackingAdmin(ImportExportModelAdmin):
class StockItemTestResultAdmin(admin.ModelAdmin):
"""Admin class for StockItemTestResult."""
list_display = ('stock_item', 'test', 'result', 'value')
list_display = ('stock_item', 'test_name', 'result', 'value')
autocomplete_fields = ['stock_item']

View File

@ -38,6 +38,7 @@ from InvenTree.filters import (
from InvenTree.helpers import (
DownloadFile,
extract_serial_numbers,
generateTestKey,
is_ajax,
isNull,
str2bool,
@ -1188,22 +1189,87 @@ class StockAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
serializer_class = StockSerializers.StockItemAttachmentSerializer
class StockItemTestResultDetail(RetrieveUpdateDestroyAPI):
class StockItemTestResultMixin:
"""Mixin class for the StockItemTestResult API endpoints."""
queryset = StockItemTestResult.objects.all()
serializer_class = StockSerializers.StockItemTestResultSerializer
def get_serializer_context(self):
"""Extend serializer context."""
ctx = super().get_serializer_context()
ctx['request'] = self.request
return ctx
def get_serializer(self, *args, **kwargs):
"""Set context before returning serializer."""
try:
kwargs['user_detail'] = str2bool(
self.request.query_params.get('user_detail', False)
)
kwargs['template_detail'] = str2bool(
self.request.query_params.get('template_detail', False)
)
except Exception:
pass
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
class StockItemTestResultDetail(StockItemTestResultMixin, RetrieveUpdateDestroyAPI):
"""Detail endpoint for StockItemTestResult."""
queryset = StockItemTestResult.objects.all()
serializer_class = StockSerializers.StockItemTestResultSerializer
pass
class StockItemTestResultList(ListCreateDestroyAPIView):
class StockItemTestResultFilter(rest_filters.FilterSet):
"""API filter for the StockItemTestResult list."""
class Meta:
"""Metaclass options."""
model = StockItemTestResult
# Simple filter fields
fields = ['user', 'template', 'result', 'value']
build = rest_filters.ModelChoiceFilter(
label='Build', queryset=Build.objects.all(), field_name='stock_item__build'
)
part = rest_filters.ModelChoiceFilter(
label='Part', queryset=Part.objects.all(), field_name='stock_item__part'
)
required = rest_filters.BooleanFilter(
label='Required', field_name='template__required'
)
test = rest_filters.CharFilter(
label='Test name (case insensitive)', method='filter_test_name'
)
def filter_test_name(self, queryset, name, value):
"""Filter by test name.
This method is provided for legacy support,
where the StockItemTestResult model had a "test" field.
Now the "test" name is stored against the PartTestTemplate model
"""
key = generateTestKey(value)
return queryset.filter(template__key=key)
class StockItemTestResultList(StockItemTestResultMixin, ListCreateDestroyAPIView):
"""API endpoint for listing (and creating) a StockItemTestResult object."""
queryset = StockItemTestResult.objects.all()
serializer_class = StockSerializers.StockItemTestResultSerializer
filterset_class = StockItemTestResultFilter
filter_backends = SEARCH_ORDER_FILTER
filterset_fields = ['test', 'user', 'result', 'value']
filterset_fields = ['user', 'template', 'result', 'value']
ordering_fields = ['date', 'result']
ordering = 'date'
@ -1213,18 +1279,6 @@ class StockItemTestResultList(ListCreateDestroyAPIView):
queryset = super().filter_queryset(queryset)
# Filter by 'build'
build = params.get('build', None)
if build is not None:
try:
build = Build.objects.get(pk=build)
queryset = queryset.filter(stock_item__build=build)
except (ValueError, Build.DoesNotExist):
pass
# Filter by stock item
item = params.get('stock_item', None)
@ -1251,19 +1305,6 @@ class StockItemTestResultList(ListCreateDestroyAPIView):
return queryset
def get_serializer(self, *args, **kwargs):
"""Set context before returning serializer."""
try:
kwargs['user_detail'] = str2bool(
self.request.query_params.get('user_detail', False)
)
except Exception:
pass
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
def perform_create(self, serializer):
"""Create a new test result object.

View File

@ -2,7 +2,7 @@
pk: 1
fields:
stock_item: 105
test: "Firmware Version"
template: 10
value: "0xA1B2C3D4"
result: True
date: 2020-02-02
@ -11,7 +11,7 @@
pk: 2
fields:
stock_item: 105
test: "Settings Checksum"
template: 9
value: "0xAABBCCDD"
result: True
date: 2020-02-02
@ -20,7 +20,7 @@
pk: 3
fields:
stock_item: 105
test: "Temperature Test"
template: 8
result: False
date: 2020-05-16
notes: 'Got too hot or something'
@ -29,7 +29,7 @@
pk: 4
fields:
stock_item: 105
test: "Temperature Test"
template: 8
result: True
date: 2020-05-17
notes: 'Passed temperature test by making it cooler'
@ -38,7 +38,7 @@
pk: 5
fields:
stock_item: 522
test: 'applypaint'
template: 2
result: True
date: 2020-05-17
@ -46,7 +46,7 @@
pk: 6
fields:
stock_item: 522
test: 'applypaint'
template: 2
result: False
date: 2020-05-18
@ -54,7 +54,7 @@
pk: 7
fields:
stock_item: 522
test: 'Attach Legs'
template: 4
result: True
date: 2020-05-17
@ -62,15 +62,6 @@
pk: 8
fields:
stock_item: 522
test: 'Check that chair is GreEn'
template: 3
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
date: 2024-02-15

View File

@ -0,0 +1,20 @@
# Generated by Django 4.2.9 on 2024-02-07 03:52
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('part', '0121_auto_20240207_0344'),
('stock', '0104_alter_stockitem_purchase_price_currency'),
]
operations = [
migrations.AddField(
model_name='stockitemtestresult',
name='template',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='test_results', to='part.parttesttemplate'),
),
]

View File

@ -0,0 +1,129 @@
# Generated by Django 4.2.9 on 2024-02-07 03:53
from django.db import migrations
def set_template(apps, schema_editor):
"""Matching existing StockItemTestResult objects to their associated template.
- Use the 'key' value from the associated test object.
- Look at the referenced part first
- If no matches, look at parent part template(s)
- If still no matches, create a new PartTestTemplate object
"""
import time
import InvenTree.helpers
StockItemTestResult = apps.get_model('stock', 'stockitemtestresult')
PartTestTemplate = apps.get_model('part', 'parttesttemplate')
Part = apps.get_model('part', 'part')
# Look at any test results which do not match a template
results = StockItemTestResult.objects.filter(template=None)
parts = results.values_list('stock_item__part', flat=True).distinct()
n_results = results.count()
if n_results == 0:
return
print(f"\n{n_results} StockItemTestResult objects do not have matching templates!")
print(f"Updating test results for {len(parts)} unique parts...")
# Keep a map of test templates
part_tree_map = {}
t1 = time.time()
new_templates = 0
# For each part with missing templates, work out what templates are missing
for pk in parts:
part = Part.objects.get(pk=pk)
tree_id = part.tree_id
# Find all results matching this part
part_results = results.filter(stock_item__part=part)
test_names = part_results.values_list('test', flat=True).distinct()
key_map = part_tree_map.get(tree_id, None) or {}
for name in test_names:
template = None
key = InvenTree.helpers.generateTestKey(name)
if template := key_map.get(key, None):
# We have a template for this key
pass
elif template := PartTestTemplate.objects.filter(part__tree_id=part.tree_id, key=key).first():
# We have found an existing template for this test
pass
elif template := PartTestTemplate.objects.filter(part__tree_id=part.tree_id, test_name__iexact=name).first():
# We have found an existing template for this test
pass
# Create a new template, based on the available test information
else:
# Find the parent part template
top_level_part = part
while top_level_part.variant_of:
top_level_part = top_level_part.variant_of
template = PartTestTemplate.objects.create(
part=top_level_part,
test_name=name,
key=key,
)
new_templates += 1
# Finally, update all matching results
part_results.filter(test=name).update(template=template)
# Update the key map for this part tree
key_map[key] = template
# Update the part tree map
part_tree_map[tree_id] = key_map
t2 = time.time()
dt = t2 - t1
print(f"Updated {n_results} StockItemTestResult objects in {dt:.3f} seconds.")
if new_templates > 0:
print(f"Created {new_templates} new templates!")
# Check that there are now zero reamining results without templates
results = StockItemTestResult.objects.filter(template=None)
assert(results.count() == 0)
def remove_template(apps, schema_editor):
"""Remove template links from existing StockItemTestResult objects."""
StockItemTestResult = apps.get_model('stock', 'stockitemtestresult')
results = StockItemTestResult.objects.all()
results.update(template=None)
if results.count() > 0:
print(f"\nRemoved template links from {results.count()} StockItemTestResult objects")
class Migration(migrations.Migration):
atomic = False
dependencies = [
('stock', '0105_stockitemtestresult_template'),
('part', '0121_auto_20240207_0344')
]
operations = [
migrations.RunPython(set_template, reverse_code=remove_template),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 4.2.9 on 2024-02-07 09:01
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('part', '0121_auto_20240207_0344'),
('stock', '0106_auto_20240207_0353'),
]
operations = [
migrations.RemoveField(
model_name='stockitemtestresult',
name='test',
),
migrations.AlterField(
model_name='stockitemtestresult',
name='template',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='test_results', to='part.parttesttemplate'),
),
]

View File

@ -1550,6 +1550,52 @@ class StockItem(
result.stock_item = self
result.save()
def add_test_result(self, create_template=True, **kwargs):
"""Helper function to add a new StockItemTestResult.
The main purpose of this function is to allow lookup of the template,
based on the provided test name.
If no template is found, a new one is created (if create_template=True).
Args:
create_template: If True, create a new template if it does not exist
kwargs:
template: The ID of the associated PartTestTemplate
test_name: The name of the test (if the template is not provided)
result: The result of the test
value: The value of the test
user: The user who performed the test
notes: Any notes associated with the test
"""
template = kwargs.get('template', None)
test_name = kwargs.pop('test_name', None)
test_key = InvenTree.helpers.generateTestKey(test_name)
if template is None and test_name is not None:
# Attempt to find a matching template
template = PartModels.PartTestTemplate.objects.filter(
part__tree_id=self.part.tree_id, key=test_key
).first()
if template is None:
if create_template:
template = PartModels.PartTestTemplate.objects.create(
part=self.part, test_name=test_name
)
else:
raise ValidationError({
'template': _('Test template does not exist')
})
kwargs['template'] = template
kwargs['stock_item'] = self
return StockItemTestResult.objects.create(**kwargs)
def can_merge(self, other=None, raise_error=False, **kwargs):
"""Check if this stock item can be merged into another stock item."""
allow_mismatched_suppliers = kwargs.get('allow_mismatched_suppliers', False)
@ -1623,6 +1669,9 @@ class StockItem(
if len(other_items) == 0:
return
# Keep track of the tree IDs that are being merged
tree_ids = {self.tree_id}
user = kwargs.get('user', None)
location = kwargs.get('location', None)
notes = kwargs.get('notes', None)
@ -1634,6 +1683,8 @@ class StockItem(
if not self.can_merge(other, raise_error=raise_error, **kwargs):
return
tree_ids.add(other.tree_id)
for other in other_items:
self.quantity += other.quantity
@ -1665,6 +1716,14 @@ class StockItem(
self.location = location
self.save()
# Rebuild stock trees as required
try:
for tree_id in tree_ids:
StockItem.objects.partial_rebuild(tree_id=tree_id)
except Exception:
logger.warning('Rebuilding entire StockItem tree')
StockItem.objects.rebuild()
@transaction.atomic
def splitStock(self, quantity, location=None, user=None, **kwargs):
"""Split this stock item into two items, in the same location.
@ -1994,19 +2053,24 @@ class StockItem(
results.delete()
def getTestResults(self, test=None, result=None, user=None):
def getTestResults(self, template=None, test=None, result=None, user=None):
"""Return all test results associated with this StockItem.
Optionally can filter results by:
- Test template ID
- Test name
- Test result
- User
"""
results = self.test_results
if template:
results = results.filter(template=template)
if test:
# Filter by test name
results = results.filter(test=test)
test_key = InvenTree.helpers.generateTestKey(test)
results = results.filter(template__key=test_key)
if result is not None:
# Filter by test status
@ -2037,8 +2101,7 @@ class StockItem(
result_map = {}
for result in results:
key = InvenTree.helpers.generateTestKey(result.test)
result_map[key] = result
result_map[result.key] = result
# Do we wish to "cascade" and include test results from installed stock items?
cascade = kwargs.get('cascade', False)
@ -2098,7 +2161,7 @@ class StockItem(
def hasRequiredTests(self):
"""Return True if there are any 'required tests' associated with this StockItem."""
return self.part.getRequiredTests().count() > 0
return self.required_test_count > 0
def passedAllRequiredTests(self):
"""Returns True if this StockItem has passed all required tests."""
@ -2286,7 +2349,7 @@ class StockItemTestResult(InvenTree.models.InvenTreeMetadataModel):
Attributes:
stock_item: Link to StockItem
test: Test name (simple string matching)
template: Link to TestTemplate
result: Test result value (pass / fail / etc)
value: Recorded test output value (optional)
attachment: Link to StockItem attachment (optional)
@ -2295,6 +2358,10 @@ class StockItemTestResult(InvenTree.models.InvenTreeMetadataModel):
date: Date the test result was recorded
"""
def __str__(self):
"""Return string representation."""
return f'{self.test_name} - {self.result}'
@staticmethod
def get_api_url():
"""Return API url."""
@ -2334,14 +2401,22 @@ class StockItemTestResult(InvenTree.models.InvenTreeMetadataModel):
@property
def key(self):
"""Return key for test."""
return InvenTree.helpers.generateTestKey(self.test)
return InvenTree.helpers.generateTestKey(self.test_name)
stock_item = models.ForeignKey(
StockItem, on_delete=models.CASCADE, related_name='test_results'
)
test = models.CharField(
blank=False, max_length=100, verbose_name=_('Test'), help_text=_('Test name')
@property
def test_name(self):
"""Return the test name of the associated test template."""
return self.template.test_name
template = models.ForeignKey(
'part.parttesttemplate',
on_delete=models.CASCADE,
blank=False,
related_name='test_results',
)
result = models.BooleanField(

View File

@ -1,5 +1,6 @@
"""JSON serializers for Stock app."""
import logging
from datetime import datetime, timedelta
from decimal import Decimal
@ -23,7 +24,7 @@ import part.models as part_models
import stock.filters
from company.serializers import SupplierPartSerializer
from InvenTree.serializers import InvenTreeCurrencySerializer, InvenTreeDecimalField
from part.serializers import PartBriefSerializer
from part.serializers import PartBriefSerializer, PartTestTemplateSerializer
from .models import (
StockItem,
@ -34,6 +35,8 @@ from .models import (
StockLocationType,
)
logger = logging.getLogger('inventree')
class LocationBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Provides a brief serializer for a StockLocation object."""
@ -56,8 +59,6 @@ class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializ
fields = [
'pk',
'stock_item',
'key',
'test',
'result',
'value',
'attachment',
@ -65,6 +66,8 @@ class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializ
'user',
'user_detail',
'date',
'template',
'template_detail',
]
read_only_fields = ['pk', 'user', 'date']
@ -72,20 +75,67 @@ class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializ
def __init__(self, *args, **kwargs):
"""Add detail fields."""
user_detail = kwargs.pop('user_detail', False)
template_detail = kwargs.pop('template_detail', False)
super().__init__(*args, **kwargs)
if user_detail is not True:
self.fields.pop('user_detail')
if template_detail is not True:
self.fields.pop('template_detail')
user_detail = InvenTree.serializers.UserSerializer(source='user', read_only=True)
key = serializers.CharField(read_only=True)
template = serializers.PrimaryKeyRelatedField(
queryset=part_models.PartTestTemplate.objects.all(),
many=False,
required=False,
allow_null=True,
help_text=_('Template'),
label=_('Test template for this result'),
)
template_detail = PartTestTemplateSerializer(source='template', read_only=True)
attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField(
required=False
)
def validate(self, data):
"""Validate the test result data."""
stock_item = data['stock_item']
template = data.get('template', None)
# To support legacy API, we can accept a test name instead of a template
# In such a case, we use the test name to lookup the appropriate template
test_name = self.context['request'].data.get('test', None)
if not template and not test_name:
raise ValidationError(_('Template ID or test name must be provided'))
if not template:
test_key = InvenTree.helpers.generateTestKey(test_name)
# Find a template based on name
if template := part_models.PartTestTemplate.objects.filter(
part__tree_id=stock_item.part.tree_id, key=test_key
).first():
data['template'] = template
else:
logger.info(
"No matching test template found for '%s' - creating a new template",
test_name,
)
# Create a new test template based on the provided dasta
data['template'] = part_models.PartTestTemplate.objects.create(
part=stock_item.part, test_name=test_name
)
return super().validate(data)
class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer):
"""Brief serializers for a StockItem."""

View File

@ -292,6 +292,7 @@
constructForm('{% url "api-stock-test-result-list" %}', {
method: 'POST',
fields: stockItemTestResultFields({
part: {{ item.part.pk }},
stock_item: {{ item.pk }},
}),
title: '{% trans "Add Test Result" escape %}',

View File

@ -19,7 +19,7 @@ import part.models
from common.models import InvenTreeSetting
from InvenTree.status_codes import StockHistoryCode, StockStatus
from InvenTree.unit_test import InvenTreeAPITestCase
from part.models import Part
from part.models import Part, PartTestTemplate
from stock.models import (
StockItem,
StockItemTestResult,
@ -34,6 +34,7 @@ class StockAPITestCase(InvenTreeAPITestCase):
fixtures = [
'category',
'part',
'test_templates',
'bom',
'company',
'location',
@ -1559,6 +1560,8 @@ class StockTestResultTest(StockAPITestCase):
response = self.client.get(url)
n = len(response.data)
# Test upload using test name (legacy method)
# Note that a new test template will be created
data = {
'stock_item': 105,
'test': 'Checked Steam Valve',
@ -1569,6 +1572,9 @@ class StockTestResultTest(StockAPITestCase):
response = self.post(url, data, expected_code=201)
# Check that a new test template has been created
test_template = PartTestTemplate.objects.get(key='checkedsteamvalve')
response = self.client.get(url)
self.assertEqual(len(response.data), n + 1)
@ -1581,6 +1587,27 @@ class StockTestResultTest(StockAPITestCase):
self.assertEqual(test['value'], '150kPa')
self.assertEqual(test['user'], self.user.pk)
# Test upload using template reference (new method)
data = {
'stock_item': 105,
'template': test_template.pk,
'result': True,
'value': '75kPa',
}
response = self.post(url, data, expected_code=201)
# Check that a new test template has been created
self.assertEqual(test_template.test_results.all().count(), 2)
# List test results against the template
response = self.client.get(url, data={'template': test_template.pk})
self.assertEqual(len(response.data), 2)
for item in response.data:
self.assertEqual(item['template'], test_template.pk)
def test_post_bitmap(self):
"""2021-08-25.
@ -1598,14 +1625,15 @@ class StockTestResultTest(StockAPITestCase):
with open(image_file, 'rb') as bitmap:
data = {
'stock_item': 105,
'test': 'Checked Steam Valve',
'test': 'Temperature Test',
'result': False,
'value': '150kPa',
'notes': 'I guess there was just too much pressure?',
'value': '550C',
'notes': 'I guess there was just too much heat?',
'attachment': bitmap,
}
response = self.client.post(self.get_url(), data)
self.assertEqual(response.status_code, 201)
# Check that an attachment has been uploaded
@ -1619,23 +1647,34 @@ class StockTestResultTest(StockAPITestCase):
url = reverse('api-stock-test-result-list')
stock_item = StockItem.objects.get(pk=1)
# Ensure the part is marked as "trackable"
p = stock_item.part
p.trackable = True
p.save()
# Create some objects (via the API)
for _ii in range(50):
response = self.post(
url,
{
'stock_item': 1,
'stock_item': stock_item.pk,
'test': f'Some test {_ii}',
'result': True,
'value': 'Test result value',
},
expected_code=201,
# expected_code=201,
)
tests.append(response.data['pk'])
self.assertEqual(StockItemTestResult.objects.count(), n + 50)
# Filter test results by part
response = self.get(url, {'part': p.pk}, expected_code=200)
self.assertEqual(len(response.data), 50)
# Attempt a delete without providing items
self.delete(url, {}, expected_code=400)
@ -1838,6 +1877,7 @@ class StockMetadataAPITest(InvenTreeAPITestCase):
fixtures = [
'category',
'part',
'test_templates',
'bom',
'company',
'location',

View File

@ -133,3 +133,100 @@ class TestScheduledForDeletionMigration(MigratorTestCase):
# All the "scheduled for deletion" items have been removed
self.assertEqual(StockItem.objects.count(), 3)
class TestTestResultMigration(MigratorTestCase):
"""Unit tests for StockItemTestResult data migrations."""
migrate_from = ('stock', '0103_stock_location_types')
migrate_to = ('stock', '0107_remove_stockitemtestresult_test_and_more')
test_keys = {
'appliedpaint': 'Applied Paint',
'programmed': 'Programmed',
'checkedresultcode': 'Checked Result CODE',
}
def prepare(self):
"""Create initial data."""
Part = self.old_state.apps.get_model('part', 'part')
PartTestTemplate = self.old_state.apps.get_model('part', 'parttesttemplate')
StockItem = self.old_state.apps.get_model('stock', 'stockitem')
StockItemTestResult = self.old_state.apps.get_model(
'stock', 'stockitemtestresult'
)
# Create a test part
parent_part = Part.objects.create(
name='Parent Part',
description='A parent part',
is_template=True,
active=True,
trackable=True,
level=0,
tree_id=1,
lft=0,
rght=0,
)
# Create some child parts
children = [
Part.objects.create(
name=f'Child part {idx}',
description='A child part',
variant_of=parent_part,
active=True,
trackable=True,
level=0,
tree_id=1,
lft=0,
rght=0,
)
for idx in range(3)
]
# Create some stock items
for ii, child in enumerate(children):
for jj in range(4):
si = StockItem.objects.create(
part=child,
serial=str(1 + ii * jj),
quantity=1,
tree_id=0,
level=0,
lft=0,
rght=0,
)
# Create some test results
for _k, v in self.test_keys.items():
StockItemTestResult.objects.create(
stock_item=si, test=v, result=True, value=f'Result: {ii} : {jj}'
)
# Check initial record counts
self.assertEqual(PartTestTemplate.objects.count(), 0)
self.assertEqual(StockItemTestResult.objects.count(), 36)
def test_migration(self):
"""Test that the migrations were applied as expected."""
Part = self.new_state.apps.get_model('part', 'part')
PartTestTemplate = self.new_state.apps.get_model('part', 'parttesttemplate')
StockItem = self.new_state.apps.get_model('stock', 'stockitem')
StockItemTestResult = self.new_state.apps.get_model(
'stock', 'stockitemtestresult'
)
# Test that original record counts are correct
self.assertEqual(Part.objects.count(), 4)
self.assertEqual(StockItem.objects.count(), 12)
self.assertEqual(StockItemTestResult.objects.count(), 36)
# Two more test templates should have been created
self.assertEqual(PartTestTemplate.objects.count(), 3)
for k in self.test_keys.keys():
self.assertTrue(PartTestTemplate.objects.filter(key=k).exists())
for result in StockItemTestResult.objects.all():
self.assertIsNotNone(result.template)

View File

@ -12,7 +12,7 @@ from company.models import Company
from InvenTree.status_codes import StockHistoryCode
from InvenTree.unit_test import InvenTreeTestCase
from order.models import SalesOrder
from part.models import Part
from part.models import Part, PartTestTemplate
from .models import StockItem, StockItemTestResult, StockItemTracking, StockLocation
@ -1086,31 +1086,51 @@ class TestResultTest(StockTestBase):
self.assertEqual(status['total'], 5)
self.assertEqual(status['passed'], 2)
self.assertEqual(status['failed'], 2)
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 = StockItemTestResult.objects.get(pk=8)
test.result = False
test.save()
status = item.requiredTestStatus()
self.assertEqual(status['total'], 5)
self.assertEqual(status['passed'], 1)
self.assertEqual(status['failed'], 2)
template = PartTestTemplate.objects.get(pk=3)
StockItemTestResult.objects.create(
stock_item=item, test='sew cushion', result=True
stock_item=item, template=template, result=True
)
# Still should be failing at this point,
# as the most recent "apply paint" test was False
self.assertFalse(item.passedAllRequiredTests())
template = PartTestTemplate.objects.get(pk=2)
# Add a new test result against this required test
StockItemTestResult.objects.create(
stock_item=item,
test='apply paint',
template=template,
date=datetime.datetime(2022, 12, 12),
result=True,
)
self.assertFalse(item.passedAllRequiredTests())
# Generate a passing result for all required tests
for template in item.part.getRequiredTests():
StockItemTestResult.objects.create(
stock_item=item,
template=template,
result=True,
date=datetime.datetime(2025, 12, 12),
)
self.assertTrue(item.passedAllRequiredTests())
def test_duplicate_item_tests(self):
@ -1140,17 +1160,9 @@ class TestResultTest(StockTestBase):
item.save()
# Do some tests!
StockItemTestResult.objects.create(
stock_item=item, test='Firmware', result=True
)
StockItemTestResult.objects.create(
stock_item=item, test='Paint Color', result=True, value='Red'
)
StockItemTestResult.objects.create(
stock_item=item, test='Applied Sticker', result=False
)
item.add_test_result(test_name='Firmware', result=True)
item.add_test_result(test_name='Paint Color', result=True, value='Red')
item.add_test_result(test_name='Applied Sticker', result=False)
self.assertEqual(item.test_results.count(), 3)
self.assertEqual(item.quantity, 50)
@ -1163,7 +1175,7 @@ class TestResultTest(StockTestBase):
self.assertEqual(item.test_results.count(), 3)
self.assertEqual(item2.test_results.count(), 3)
StockItemTestResult.objects.create(stock_item=item2, test='A new test')
item2.add_test_result(test_name='A new test')
self.assertEqual(item.test_results.count(), 3)
self.assertEqual(item2.test_results.count(), 4)
@ -1172,7 +1184,7 @@ class TestResultTest(StockTestBase):
item2.serializeStock(1, [100], self.user)
# Add a test result to the parent *after* serialization
StockItemTestResult.objects.create(stock_item=item2, test='abcde')
item2.add_test_result(test_name='abcde')
self.assertEqual(item2.test_results.count(), 5)
@ -1201,11 +1213,20 @@ class TestResultTest(StockTestBase):
)
# Now, create some test results against the sub item
# Ensure there is a matching PartTestTemplate
if template := PartTestTemplate.objects.filter(
part=item.part, key='firmwareversion'
).first():
pass
else:
template = PartTestTemplate.objects.create(
part=item.part, test_name='Firmware Version', required=True
)
# First test is overshadowed by the same test for the parent part
StockItemTestResult.objects.create(
stock_item=sub_item,
test='firmware version',
template=template,
date=datetime.datetime.now().date(),
result=True,
)
@ -1214,10 +1235,19 @@ class TestResultTest(StockTestBase):
tests = item.testResultMap(include_installed=True)
self.assertEqual(len(tests), 3)
if template := PartTestTemplate.objects.filter(
part=item.part, key='somenewtest'
).first():
pass
else:
template = PartTestTemplate.objects.create(
part=item.part, test_name='Some New Test', required=True
)
# Now, add a *unique* test result for the sub item
StockItemTestResult.objects.create(
stock_item=sub_item,
test='some new test',
template=template,
date=datetime.datetime.now().date(),
result=False,
value='abcde',

View File

@ -68,6 +68,8 @@ function getModelRenderer(model) {
return renderPartCategory;
case 'partparametertemplate':
return renderPartParameterTemplate;
case 'parttesttemplate':
return renderPartTestTemplate;
case 'purchaseorder':
return renderPurchaseOrder;
case 'salesorder':
@ -483,6 +485,18 @@ function renderPartParameterTemplate(data, parameters={}) {
}
function renderPartTestTemplate(data, parameters={}) {
return renderModel(
{
text: data.test_name,
textSecondary: data.description,
},
parameters
);
}
// Renderer for "ManufacturerPart" model
function renderManufacturerPart(data, parameters={}) {

View File

@ -2867,6 +2867,18 @@ function loadPartTestTemplateTable(table, options) {
field: 'test_name',
title: '{% trans "Test Name" %}',
sortable: true,
formatter: function(value, row) {
let html = value;
if (row.results && row.results > 0) {
html += `
<span class='badge bg-dark rounded-pill float-right' title='${row.results} {% trans "results" %}'>
${row.results}
</span>`;
}
return html;
}
},
{
field: 'description',
@ -2909,7 +2921,7 @@ function loadPartTestTemplateTable(table, options) {
} else {
var text = '{% trans "This test is defined for a parent part" %}';
return renderLink(text, `/part/${row.part}/tests/`);
return renderLink(text, `/part/${row.part}/?display=test-templates`);
}
}
}

View File

@ -1381,7 +1381,11 @@ function formatDate(row) {
/* Construct set of default fields for a StockItemTestResult */
function stockItemTestResultFields(options={}) {
let fields = {
test: {},
template: {
filters: {
include_inherited: true,
}
},
result: {},
value: {},
attachment: {},
@ -1393,6 +1397,10 @@ function stockItemTestResultFields(options={}) {
},
};
if (options.part) {
fields.template.filters.part = options.part;
}
if (options.stock_item) {
fields.stock_item.value = options.stock_item;
}
@ -1412,6 +1420,7 @@ function loadStockTestResultsTable(table, options) {
let params = {
part: options.part,
include_inherited: true,
};
var filters = loadTableFilters(filterKey, params);
@ -1424,17 +1433,16 @@ function loadStockTestResultsTable(table, options) {
let html = '';
if (row.requires_attachment == false && row.requires_value == false && !row.result) {
if (row.parent != parent_node && row.requires_attachment == false && row.requires_value == false && !row.result) {
// Enable a "quick tick" option for this test result
html += makeIconButton('fa-check-circle icon-green', 'button-test-tick', row.test_name, '{% trans "Pass test" %}');
}
html += makeIconButton('fa-plus icon-green', 'button-test-add', row.test_name, '{% trans "Add test result" %}');
html += makeIconButton('fa-plus icon-green', 'button-test-add', row.templateId, '{% trans "Add test result" %}');
if (!grouped && row.result != null) {
var pk = row.pk;
html += makeEditButton('button-test-edit', pk, '{% trans "Edit test result" %}');
html += makeDeleteButton('button-test-delete', pk, '{% trans "Delete test result" %}');
html += makeEditButton('button-test-edit', row.testId, '{% trans "Edit test result" %}');
html += makeDeleteButton('button-test-delete', row.testId, '{% trans "Delete test result" %}');
}
return wrapButtons(html);
@ -1532,9 +1540,14 @@ function loadStockTestResultsTable(table, options) {
],
onLoadSuccess: function(tableData) {
// Set "parent" for each existing row
tableData.forEach(function(item, idx) {
tableData[idx].parent = parent_node;
// Construct an initial dataset based on the returned templates
let results = tableData.map((template) => {
return {
...template,
templateId: template.pk,
parent: parent_node,
results: []
};
});
// Once the test template data are loaded, query for test results
@ -1545,6 +1558,7 @@ function loadStockTestResultsTable(table, options) {
stock_item: options.stock_item,
user_detail: true,
attachment_detail: true,
template_detail: false,
ordering: '-date',
};
@ -1561,54 +1575,40 @@ function loadStockTestResultsTable(table, options) {
query_params,
{
success: function(data) {
// Iterate through the returned test data
data.forEach(function(item) {
var match = false;
var override = false;
data.sort((a, b) => {
return a.pk < b.pk;
}).forEach((row) => {
let idx = results.findIndex((template) => {
return template.templateId == row.template;
});
// Extract the simplified test key
var key = item.key;
if (idx > -1) {
// Attempt to associate this result with an existing test
for (var idx = 0; idx < tableData.length; idx++) {
results[idx].results.push(row);
var row = tableData[idx];
if (key == row.key) {
item.test_name = row.test_name;
item.test_description = row.description;
item.required = row.required;
if (row.result == null) {
item.parent = parent_node;
tableData[idx] = item;
override = true;
} else {
item.parent = row.pk;
}
match = true;
break;
// Check if a test result is already recorded
if (results[idx].testId) {
// Push this result into the results array
results.push({
...results[idx],
...row,
parent: results[idx].templateId,
testId: row.pk,
});
} else {
// First result - update the parent row
results[idx] = {
...row,
...results[idx],
testId: row.pk,
};
}
}
// No match could be found
if (!match) {
item.test_name = item.test;
item.parent = parent_node;
}
if (!override) {
tableData.push(item);
}
});
// Push data back into the table
table.bootstrapTable('load', tableData);
table.bootstrapTable('load', results);
}
}
);
@ -1645,25 +1645,17 @@ function loadStockTestResultsTable(table, options) {
$(table).on('click', '.button-test-add', function() {
var button = $(this);
var test_name = button.attr('pk');
var templateId = button.attr('pk');
let fields = stockItemTestResultFields();
fields['stock_item']['value'] = options.stock_item;
fields['template']['value'] = templateId;
fields['template']['filters']['part'] = options.part;
constructForm('{% url "api-stock-test-result-list" %}', {
method: 'POST',
fields: {
test: {
value: test_name,
},
result: {},
value: {},
attachment: {},
notes: {
icon: 'fa-sticky-note',
},
stock_item: {
value: options.stock_item,
hidden: true,
}
},
fields: fields,
title: '{% trans "Add Test Result" %}',
onSuccess: reloadTestTable,
});
@ -1692,11 +1684,9 @@ function loadStockTestResultsTable(table, options) {
var url = `/api/stock/test/${pk}/`;
var row = $(table).bootstrapTable('getRowByUniqueId', pk);
var html = `
<div class='alert alert-block alert-danger'>
<strong>{% trans "Delete test result" %}:</strong> ${row.test_name || row.test || row.key}
<strong>{% trans "Delete test result" %}</strong>
</div>`;
constructForm(url, {