mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +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:
parent
ad1c1ae604
commit
0f51127adf
@ -1,12 +1,17 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
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 CRUD API endpoints
|
||||||
- Adds machine settings API endpoints
|
- Adds machine settings API endpoints
|
||||||
- Adds machine restart API endpoint
|
- Adds machine restart API endpoint
|
||||||
|
@ -76,16 +76,23 @@ def extract_int(reference, clip=0x7FFFFFFF, allow_negative=False):
|
|||||||
return ref_int
|
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.
|
"""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.
|
Tests must be named such that they will have unique keys.
|
||||||
"""
|
"""
|
||||||
|
if test_name is None:
|
||||||
|
test_name = ''
|
||||||
|
|
||||||
key = test_name.strip().lower()
|
key = test_name.strip().lower()
|
||||||
key = key.replace(' ', '')
|
key = key.replace(' ', '')
|
||||||
|
|
||||||
# Remove any characters that cannot be used to represent a variable
|
# 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
|
return key
|
||||||
|
|
||||||
|
19
InvenTree/InvenTree/management/commands/check_migrations.py
Normal file
19
InvenTree/InvenTree/management/commands/check_migrations.py
Normal 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')
|
@ -509,6 +509,20 @@ class TestHelpers(TestCase):
|
|||||||
self.assertNotIn(PartCategory, models)
|
self.assertNotIn(PartCategory, models)
|
||||||
self.assertNotIn(InvenTreeSetting, 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):
|
class TestQuoteWrap(TestCase):
|
||||||
"""Tests for string wrapping."""
|
"""Tests for string wrapping."""
|
||||||
|
@ -655,9 +655,10 @@ class BuildTest(BuildTestBase):
|
|||||||
# let's complete the required test and see if it could be saved
|
# let's complete the required test and see if it could be saved
|
||||||
StockItemTestResult.objects.create(
|
StockItemTestResult.objects.create(
|
||||||
stock_item=self.stockitem_with_required_test,
|
stock_item=self.stockitem_with_required_test,
|
||||||
test=self.test_template_required.test_name,
|
template=self.test_template_required,
|
||||||
result=True
|
result=True
|
||||||
)
|
)
|
||||||
|
|
||||||
self.build_w_tests_trackable.complete_build_output(self.stockitem_with_required_test, None)
|
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
|
# let's see if a non required test could be saved
|
||||||
|
@ -363,6 +363,7 @@ class PartTestTemplateAdmin(admin.ModelAdmin):
|
|||||||
"""Admin class for the PartTestTemplate model."""
|
"""Admin class for the PartTestTemplate model."""
|
||||||
|
|
||||||
list_display = ('part', 'test_name', 'required')
|
list_display = ('part', 'test_name', 'required')
|
||||||
|
readonly_fields = ['key']
|
||||||
|
|
||||||
autocomplete_fields = ('part',)
|
autocomplete_fields = ('part',)
|
||||||
|
|
||||||
|
@ -389,29 +389,53 @@ class PartTestTemplateFilter(rest_filters.FilterSet):
|
|||||||
|
|
||||||
Note that for the 'part' field, we also include any parts "above" the specified part.
|
Note that for the 'part' field, we also include any parts "above" the specified part.
|
||||||
"""
|
"""
|
||||||
variants = part.get_ancestors(include_self=True)
|
include_inherited = str2bool(
|
||||||
return queryset.filter(part__in=variants)
|
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."""
|
"""Detail endpoint for PartTestTemplate model."""
|
||||||
|
|
||||||
queryset = PartTestTemplate.objects.all()
|
pass
|
||||||
serializer_class = part_serializers.PartTestTemplateSerializer
|
|
||||||
|
|
||||||
|
|
||||||
class PartTestTemplateList(ListCreateAPI):
|
class PartTestTemplateList(PartTestTemplateMixin, ListCreateAPI):
|
||||||
"""API endpoint for listing (and creating) a PartTestTemplate."""
|
"""API endpoint for listing (and creating) a PartTestTemplate."""
|
||||||
|
|
||||||
queryset = PartTestTemplate.objects.all()
|
|
||||||
serializer_class = part_serializers.PartTestTemplateSerializer
|
|
||||||
filterset_class = PartTestTemplateFilter
|
filterset_class = PartTestTemplateFilter
|
||||||
|
|
||||||
filter_backends = SEARCH_ORDER_FILTER
|
filter_backends = SEARCH_ORDER_FILTER
|
||||||
|
|
||||||
search_fields = ['test_name', 'description']
|
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'
|
ordering = 'test_name'
|
||||||
|
|
||||||
|
@ -4,30 +4,35 @@
|
|||||||
fields:
|
fields:
|
||||||
part: 10000
|
part: 10000
|
||||||
test_name: Test strength of chair
|
test_name: Test strength of chair
|
||||||
|
key: 'teststrengthofchair'
|
||||||
|
|
||||||
- model: part.parttesttemplate
|
- model: part.parttesttemplate
|
||||||
pk: 2
|
pk: 2
|
||||||
fields:
|
fields:
|
||||||
part: 10000
|
part: 10000
|
||||||
test_name: Apply paint
|
test_name: Apply paint
|
||||||
|
key: 'applypaint'
|
||||||
|
|
||||||
- model: part.parttesttemplate
|
- model: part.parttesttemplate
|
||||||
pk: 3
|
pk: 3
|
||||||
fields:
|
fields:
|
||||||
part: 10000
|
part: 10000
|
||||||
test_name: Sew cushion
|
test_name: Sew cushion
|
||||||
|
key: 'sewcushion'
|
||||||
|
|
||||||
- model: part.parttesttemplate
|
- model: part.parttesttemplate
|
||||||
pk: 4
|
pk: 4
|
||||||
fields:
|
fields:
|
||||||
part: 10000
|
part: 10000
|
||||||
test_name: Attach legs
|
test_name: Attach legs
|
||||||
|
key: 'attachlegs'
|
||||||
|
|
||||||
- model: part.parttesttemplate
|
- model: part.parttesttemplate
|
||||||
pk: 5
|
pk: 5
|
||||||
fields:
|
fields:
|
||||||
part: 10000
|
part: 10000
|
||||||
test_name: Record weight
|
test_name: Record weight
|
||||||
|
key: 'recordweight'
|
||||||
required: false
|
required: false
|
||||||
|
|
||||||
# Add some tests for one of the variants
|
# Add some tests for one of the variants
|
||||||
@ -35,12 +40,30 @@
|
|||||||
pk: 6
|
pk: 6
|
||||||
fields:
|
fields:
|
||||||
part: 10003
|
part: 10003
|
||||||
test_name: Check that chair is green
|
test_name: Check chair is green
|
||||||
|
key: 'checkchairisgreen'
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- model: part.parttesttemplate
|
- model: part.parttesttemplate
|
||||||
pk: 7
|
pk: 8
|
||||||
fields:
|
fields:
|
||||||
part: 10004
|
part: 25
|
||||||
test_name: Check that chair is especially green
|
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
|
required: False
|
||||||
|
18
InvenTree/part/migrations/0120_parttesttemplate_key.py
Normal file
18
InvenTree/part/migrations/0120_parttesttemplate_key.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
29
InvenTree/part/migrations/0121_auto_20240207_0344.py
Normal file
29
InvenTree/part/migrations/0121_auto_20240207_0344.py
Normal 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)
|
||||||
|
]
|
@ -3393,6 +3393,10 @@ class PartTestTemplate(InvenTree.models.InvenTreeMetadataModel):
|
|||||||
run on the model (refer to the validate_unique function).
|
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
|
@staticmethod
|
||||||
def get_api_url():
|
def get_api_url():
|
||||||
"""Return the list API endpoint URL associated with the PartTestTemplate model."""
|
"""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."""
|
"""Clean fields for the PartTestTemplate model."""
|
||||||
self.test_name = self.test_name.strip()
|
self.test_name = self.test_name.strip()
|
||||||
|
|
||||||
|
self.key = helpers.generateTestKey(self.test_name)
|
||||||
|
|
||||||
self.validate_unique()
|
self.validate_unique()
|
||||||
super().clean()
|
super().clean()
|
||||||
|
|
||||||
@ -3418,30 +3424,18 @@ class PartTestTemplate(InvenTree.models.InvenTreeMetadataModel):
|
|||||||
'part': _('Test templates can only be created for trackable parts')
|
'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(
|
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 tests.exists():
|
||||||
if self.pk is not None:
|
raise ValidationError({
|
||||||
tests = tests.exclude(pk=self.pk)
|
'test_name': _('Test with this name already exists for this part')
|
||||||
|
})
|
||||||
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)
|
super().validate_unique(exclude)
|
||||||
|
|
||||||
@property
|
|
||||||
def key(self):
|
|
||||||
"""Generate a key for this test."""
|
|
||||||
return helpers.generateTestKey(self.test_name)
|
|
||||||
|
|
||||||
part = models.ForeignKey(
|
part = models.ForeignKey(
|
||||||
Part,
|
Part,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
@ -3457,6 +3451,13 @@ class PartTestTemplate(InvenTree.models.InvenTreeMetadataModel):
|
|||||||
help_text=_('Enter a name for the test'),
|
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(
|
description = models.CharField(
|
||||||
blank=False,
|
blank=False,
|
||||||
null=True,
|
null=True,
|
||||||
|
@ -156,9 +156,20 @@ class PartTestTemplateSerializer(InvenTree.serializers.InvenTreeModelSerializer)
|
|||||||
'required',
|
'required',
|
||||||
'requires_value',
|
'requires_value',
|
||||||
'requires_attachment',
|
'requires_attachment',
|
||||||
|
'results',
|
||||||
]
|
]
|
||||||
|
|
||||||
key = serializers.CharField(read_only=True)
|
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):
|
class PartSalePriceSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||||
|
@ -806,14 +806,14 @@ class PartAPITest(PartAPITestBase):
|
|||||||
response = self.get(url)
|
response = self.get(url)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
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
|
# Request for a particular part
|
||||||
response = self.get(url, data={'part': 10000})
|
response = self.get(url, data={'part': 10000})
|
||||||
self.assertEqual(len(response.data), 5)
|
self.assertEqual(len(response.data), 5)
|
||||||
|
|
||||||
response = self.get(url, data={'part': 10004})
|
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)
|
# Try to post a new object (missing description)
|
||||||
response = self.post(
|
response = self.post(
|
||||||
|
@ -229,3 +229,47 @@ class TestPartParameterTemplateMigration(MigratorTestCase):
|
|||||||
|
|
||||||
self.assertEqual(template.choices, '')
|
self.assertEqual(template.choices, '')
|
||||||
self.assertEqual(template.checkbox, False)
|
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)
|
||||||
|
@ -378,8 +378,8 @@ class TestTemplateTest(TestCase):
|
|||||||
# Test the lowest-level part which has more associated tests
|
# Test the lowest-level part which has more associated tests
|
||||||
variant = Part.objects.get(pk=10004)
|
variant = Part.objects.get(pk=10004)
|
||||||
|
|
||||||
self.assertEqual(variant.getTestTemplates().count(), 7)
|
self.assertEqual(variant.getTestTemplates().count(), 6)
|
||||||
self.assertEqual(variant.getTestTemplates(include_parent=False).count(), 1)
|
self.assertEqual(variant.getTestTemplates(include_parent=False).count(), 0)
|
||||||
self.assertEqual(variant.getTestTemplates(required=True).count(), 5)
|
self.assertEqual(variant.getTestTemplates(required=True).count(), 5)
|
||||||
|
|
||||||
def test_uniqueness(self):
|
def test_uniqueness(self):
|
||||||
@ -389,21 +389,29 @@ class TestTemplateTest(TestCase):
|
|||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
PartTestTemplate.objects.create(part=variant, test_name='Record weight')
|
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):
|
with self.assertRaises(ValidationError):
|
||||||
PartTestTemplate.objects.create(
|
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
|
# Also should fail if we attempt to create a test that would generate the same key
|
||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
PartTestTemplate.objects.create(
|
template = PartTestTemplate.objects.create(
|
||||||
part=variant, test_name='ReCoRD weiGHT '
|
part=variant, test_name='ReCoRD weiGHT '
|
||||||
)
|
)
|
||||||
|
|
||||||
|
template.clean()
|
||||||
|
|
||||||
# But we should be able to create a new one!
|
# But we should be able to create a new one!
|
||||||
n = variant.getTestTemplates().count()
|
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)
|
self.assertEqual(variant.getTestTemplates().count(), n + 1)
|
||||||
|
|
||||||
|
@ -194,6 +194,7 @@ class ReportTest(InvenTreeAPITestCase):
|
|||||||
'part',
|
'part',
|
||||||
'company',
|
'company',
|
||||||
'location',
|
'location',
|
||||||
|
'test_templates',
|
||||||
'supplier_part',
|
'supplier_part',
|
||||||
'stock',
|
'stock',
|
||||||
'stock_tests',
|
'stock_tests',
|
||||||
|
@ -317,6 +317,6 @@ class StockTrackingAdmin(ImportExportModelAdmin):
|
|||||||
class StockItemTestResultAdmin(admin.ModelAdmin):
|
class StockItemTestResultAdmin(admin.ModelAdmin):
|
||||||
"""Admin class for StockItemTestResult."""
|
"""Admin class for StockItemTestResult."""
|
||||||
|
|
||||||
list_display = ('stock_item', 'test', 'result', 'value')
|
list_display = ('stock_item', 'test_name', 'result', 'value')
|
||||||
|
|
||||||
autocomplete_fields = ['stock_item']
|
autocomplete_fields = ['stock_item']
|
||||||
|
@ -38,6 +38,7 @@ from InvenTree.filters import (
|
|||||||
from InvenTree.helpers import (
|
from InvenTree.helpers import (
|
||||||
DownloadFile,
|
DownloadFile,
|
||||||
extract_serial_numbers,
|
extract_serial_numbers,
|
||||||
|
generateTestKey,
|
||||||
is_ajax,
|
is_ajax,
|
||||||
isNull,
|
isNull,
|
||||||
str2bool,
|
str2bool,
|
||||||
@ -1188,22 +1189,87 @@ class StockAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
|
|||||||
serializer_class = StockSerializers.StockItemAttachmentSerializer
|
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."""
|
"""Detail endpoint for StockItemTestResult."""
|
||||||
|
|
||||||
queryset = StockItemTestResult.objects.all()
|
pass
|
||||||
serializer_class = StockSerializers.StockItemTestResultSerializer
|
|
||||||
|
|
||||||
|
|
||||||
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."""
|
"""API endpoint for listing (and creating) a StockItemTestResult object."""
|
||||||
|
|
||||||
queryset = StockItemTestResult.objects.all()
|
filterset_class = StockItemTestResultFilter
|
||||||
serializer_class = StockSerializers.StockItemTestResultSerializer
|
|
||||||
|
|
||||||
filter_backends = SEARCH_ORDER_FILTER
|
filter_backends = SEARCH_ORDER_FILTER
|
||||||
|
|
||||||
filterset_fields = ['test', 'user', 'result', 'value']
|
filterset_fields = ['user', 'template', 'result', 'value']
|
||||||
|
ordering_fields = ['date', 'result']
|
||||||
|
|
||||||
ordering = 'date'
|
ordering = 'date'
|
||||||
|
|
||||||
@ -1213,18 +1279,6 @@ class StockItemTestResultList(ListCreateDestroyAPIView):
|
|||||||
|
|
||||||
queryset = super().filter_queryset(queryset)
|
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
|
# Filter by stock item
|
||||||
item = params.get('stock_item', None)
|
item = params.get('stock_item', None)
|
||||||
|
|
||||||
@ -1251,19 +1305,6 @@ class StockItemTestResultList(ListCreateDestroyAPIView):
|
|||||||
|
|
||||||
return queryset
|
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):
|
def perform_create(self, serializer):
|
||||||
"""Create a new test result object.
|
"""Create a new test result object.
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
pk: 1
|
pk: 1
|
||||||
fields:
|
fields:
|
||||||
stock_item: 105
|
stock_item: 105
|
||||||
test: "Firmware Version"
|
template: 10
|
||||||
value: "0xA1B2C3D4"
|
value: "0xA1B2C3D4"
|
||||||
result: True
|
result: True
|
||||||
date: 2020-02-02
|
date: 2020-02-02
|
||||||
@ -11,7 +11,7 @@
|
|||||||
pk: 2
|
pk: 2
|
||||||
fields:
|
fields:
|
||||||
stock_item: 105
|
stock_item: 105
|
||||||
test: "Settings Checksum"
|
template: 9
|
||||||
value: "0xAABBCCDD"
|
value: "0xAABBCCDD"
|
||||||
result: True
|
result: True
|
||||||
date: 2020-02-02
|
date: 2020-02-02
|
||||||
@ -20,7 +20,7 @@
|
|||||||
pk: 3
|
pk: 3
|
||||||
fields:
|
fields:
|
||||||
stock_item: 105
|
stock_item: 105
|
||||||
test: "Temperature Test"
|
template: 8
|
||||||
result: False
|
result: False
|
||||||
date: 2020-05-16
|
date: 2020-05-16
|
||||||
notes: 'Got too hot or something'
|
notes: 'Got too hot or something'
|
||||||
@ -29,7 +29,7 @@
|
|||||||
pk: 4
|
pk: 4
|
||||||
fields:
|
fields:
|
||||||
stock_item: 105
|
stock_item: 105
|
||||||
test: "Temperature Test"
|
template: 8
|
||||||
result: True
|
result: True
|
||||||
date: 2020-05-17
|
date: 2020-05-17
|
||||||
notes: 'Passed temperature test by making it cooler'
|
notes: 'Passed temperature test by making it cooler'
|
||||||
@ -38,7 +38,7 @@
|
|||||||
pk: 5
|
pk: 5
|
||||||
fields:
|
fields:
|
||||||
stock_item: 522
|
stock_item: 522
|
||||||
test: 'applypaint'
|
template: 2
|
||||||
result: True
|
result: True
|
||||||
date: 2020-05-17
|
date: 2020-05-17
|
||||||
|
|
||||||
@ -46,7 +46,7 @@
|
|||||||
pk: 6
|
pk: 6
|
||||||
fields:
|
fields:
|
||||||
stock_item: 522
|
stock_item: 522
|
||||||
test: 'applypaint'
|
template: 2
|
||||||
result: False
|
result: False
|
||||||
date: 2020-05-18
|
date: 2020-05-18
|
||||||
|
|
||||||
@ -54,7 +54,7 @@
|
|||||||
pk: 7
|
pk: 7
|
||||||
fields:
|
fields:
|
||||||
stock_item: 522
|
stock_item: 522
|
||||||
test: 'Attach Legs'
|
template: 4
|
||||||
result: True
|
result: True
|
||||||
date: 2020-05-17
|
date: 2020-05-17
|
||||||
|
|
||||||
@ -62,15 +62,6 @@
|
|||||||
pk: 8
|
pk: 8
|
||||||
fields:
|
fields:
|
||||||
stock_item: 522
|
stock_item: 522
|
||||||
test: 'Check that chair is GreEn'
|
template: 3
|
||||||
result: True
|
result: True
|
||||||
date: 2020-05-17
|
date: 2024-02-15
|
||||||
|
|
||||||
- model: stock.stockitemtestresult
|
|
||||||
pk: 12345
|
|
||||||
fields:
|
|
||||||
stock_item: 522
|
|
||||||
test: 'test strength of chair'
|
|
||||||
result: False
|
|
||||||
value: 100kg
|
|
||||||
date: 2020-05-17
|
|
||||||
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
129
InvenTree/stock/migrations/0106_auto_20240207_0353.py
Normal file
129
InvenTree/stock/migrations/0106_auto_20240207_0353.py
Normal 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),
|
||||||
|
]
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
@ -1550,6 +1550,52 @@ class StockItem(
|
|||||||
result.stock_item = self
|
result.stock_item = self
|
||||||
result.save()
|
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):
|
def can_merge(self, other=None, raise_error=False, **kwargs):
|
||||||
"""Check if this stock item can be merged into another stock item."""
|
"""Check if this stock item can be merged into another stock item."""
|
||||||
allow_mismatched_suppliers = kwargs.get('allow_mismatched_suppliers', False)
|
allow_mismatched_suppliers = kwargs.get('allow_mismatched_suppliers', False)
|
||||||
@ -1623,6 +1669,9 @@ class StockItem(
|
|||||||
if len(other_items) == 0:
|
if len(other_items) == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Keep track of the tree IDs that are being merged
|
||||||
|
tree_ids = {self.tree_id}
|
||||||
|
|
||||||
user = kwargs.get('user', None)
|
user = kwargs.get('user', None)
|
||||||
location = kwargs.get('location', None)
|
location = kwargs.get('location', None)
|
||||||
notes = kwargs.get('notes', None)
|
notes = kwargs.get('notes', None)
|
||||||
@ -1634,6 +1683,8 @@ class StockItem(
|
|||||||
if not self.can_merge(other, raise_error=raise_error, **kwargs):
|
if not self.can_merge(other, raise_error=raise_error, **kwargs):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
tree_ids.add(other.tree_id)
|
||||||
|
|
||||||
for other in other_items:
|
for other in other_items:
|
||||||
self.quantity += other.quantity
|
self.quantity += other.quantity
|
||||||
|
|
||||||
@ -1665,6 +1716,14 @@ class StockItem(
|
|||||||
self.location = location
|
self.location = location
|
||||||
self.save()
|
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
|
@transaction.atomic
|
||||||
def splitStock(self, quantity, location=None, user=None, **kwargs):
|
def splitStock(self, quantity, location=None, user=None, **kwargs):
|
||||||
"""Split this stock item into two items, in the same location.
|
"""Split this stock item into two items, in the same location.
|
||||||
@ -1994,19 +2053,24 @@ class StockItem(
|
|||||||
|
|
||||||
results.delete()
|
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.
|
"""Return all test results associated with this StockItem.
|
||||||
|
|
||||||
Optionally can filter results by:
|
Optionally can filter results by:
|
||||||
|
- Test template ID
|
||||||
- Test name
|
- Test name
|
||||||
- Test result
|
- Test result
|
||||||
- User
|
- User
|
||||||
"""
|
"""
|
||||||
results = self.test_results
|
results = self.test_results
|
||||||
|
|
||||||
|
if template:
|
||||||
|
results = results.filter(template=template)
|
||||||
|
|
||||||
if test:
|
if test:
|
||||||
# Filter by test name
|
# 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:
|
if result is not None:
|
||||||
# Filter by test status
|
# Filter by test status
|
||||||
@ -2037,8 +2101,7 @@ class StockItem(
|
|||||||
result_map = {}
|
result_map = {}
|
||||||
|
|
||||||
for result in results:
|
for result in results:
|
||||||
key = InvenTree.helpers.generateTestKey(result.test)
|
result_map[result.key] = result
|
||||||
result_map[key] = result
|
|
||||||
|
|
||||||
# Do we wish to "cascade" and include test results from installed stock items?
|
# Do we wish to "cascade" and include test results from installed stock items?
|
||||||
cascade = kwargs.get('cascade', False)
|
cascade = kwargs.get('cascade', False)
|
||||||
@ -2098,7 +2161,7 @@ class StockItem(
|
|||||||
|
|
||||||
def hasRequiredTests(self):
|
def hasRequiredTests(self):
|
||||||
"""Return True if there are any 'required tests' associated with this StockItem."""
|
"""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):
|
def passedAllRequiredTests(self):
|
||||||
"""Returns True if this StockItem has passed all required tests."""
|
"""Returns True if this StockItem has passed all required tests."""
|
||||||
@ -2286,7 +2349,7 @@ class StockItemTestResult(InvenTree.models.InvenTreeMetadataModel):
|
|||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
stock_item: Link to StockItem
|
stock_item: Link to StockItem
|
||||||
test: Test name (simple string matching)
|
template: Link to TestTemplate
|
||||||
result: Test result value (pass / fail / etc)
|
result: Test result value (pass / fail / etc)
|
||||||
value: Recorded test output value (optional)
|
value: Recorded test output value (optional)
|
||||||
attachment: Link to StockItem attachment (optional)
|
attachment: Link to StockItem attachment (optional)
|
||||||
@ -2295,6 +2358,10 @@ class StockItemTestResult(InvenTree.models.InvenTreeMetadataModel):
|
|||||||
date: Date the test result was recorded
|
date: Date the test result was recorded
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""Return string representation."""
|
||||||
|
return f'{self.test_name} - {self.result}'
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_api_url():
|
def get_api_url():
|
||||||
"""Return API url."""
|
"""Return API url."""
|
||||||
@ -2334,14 +2401,22 @@ class StockItemTestResult(InvenTree.models.InvenTreeMetadataModel):
|
|||||||
@property
|
@property
|
||||||
def key(self):
|
def key(self):
|
||||||
"""Return key for test."""
|
"""Return key for test."""
|
||||||
return InvenTree.helpers.generateTestKey(self.test)
|
return InvenTree.helpers.generateTestKey(self.test_name)
|
||||||
|
|
||||||
stock_item = models.ForeignKey(
|
stock_item = models.ForeignKey(
|
||||||
StockItem, on_delete=models.CASCADE, related_name='test_results'
|
StockItem, on_delete=models.CASCADE, related_name='test_results'
|
||||||
)
|
)
|
||||||
|
|
||||||
test = models.CharField(
|
@property
|
||||||
blank=False, max_length=100, verbose_name=_('Test'), help_text=_('Test name')
|
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(
|
result = models.BooleanField(
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
"""JSON serializers for Stock app."""
|
"""JSON serializers for Stock app."""
|
||||||
|
|
||||||
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
@ -23,7 +24,7 @@ import part.models as part_models
|
|||||||
import stock.filters
|
import stock.filters
|
||||||
from company.serializers import SupplierPartSerializer
|
from company.serializers import SupplierPartSerializer
|
||||||
from InvenTree.serializers import InvenTreeCurrencySerializer, InvenTreeDecimalField
|
from InvenTree.serializers import InvenTreeCurrencySerializer, InvenTreeDecimalField
|
||||||
from part.serializers import PartBriefSerializer
|
from part.serializers import PartBriefSerializer, PartTestTemplateSerializer
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
StockItem,
|
StockItem,
|
||||||
@ -34,6 +35,8 @@ from .models import (
|
|||||||
StockLocationType,
|
StockLocationType,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger('inventree')
|
||||||
|
|
||||||
|
|
||||||
class LocationBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
class LocationBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||||
"""Provides a brief serializer for a StockLocation object."""
|
"""Provides a brief serializer for a StockLocation object."""
|
||||||
@ -56,8 +59,6 @@ class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializ
|
|||||||
fields = [
|
fields = [
|
||||||
'pk',
|
'pk',
|
||||||
'stock_item',
|
'stock_item',
|
||||||
'key',
|
|
||||||
'test',
|
|
||||||
'result',
|
'result',
|
||||||
'value',
|
'value',
|
||||||
'attachment',
|
'attachment',
|
||||||
@ -65,6 +66,8 @@ class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializ
|
|||||||
'user',
|
'user',
|
||||||
'user_detail',
|
'user_detail',
|
||||||
'date',
|
'date',
|
||||||
|
'template',
|
||||||
|
'template_detail',
|
||||||
]
|
]
|
||||||
|
|
||||||
read_only_fields = ['pk', 'user', 'date']
|
read_only_fields = ['pk', 'user', 'date']
|
||||||
@ -72,20 +75,67 @@ class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializ
|
|||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
"""Add detail fields."""
|
"""Add detail fields."""
|
||||||
user_detail = kwargs.pop('user_detail', False)
|
user_detail = kwargs.pop('user_detail', False)
|
||||||
|
template_detail = kwargs.pop('template_detail', False)
|
||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
if user_detail is not True:
|
if user_detail is not True:
|
||||||
self.fields.pop('user_detail')
|
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)
|
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(
|
attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField(
|
||||||
required=False
|
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):
|
class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer):
|
||||||
"""Brief serializers for a StockItem."""
|
"""Brief serializers for a StockItem."""
|
||||||
|
@ -292,6 +292,7 @@
|
|||||||
constructForm('{% url "api-stock-test-result-list" %}', {
|
constructForm('{% url "api-stock-test-result-list" %}', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
fields: stockItemTestResultFields({
|
fields: stockItemTestResultFields({
|
||||||
|
part: {{ item.part.pk }},
|
||||||
stock_item: {{ item.pk }},
|
stock_item: {{ item.pk }},
|
||||||
}),
|
}),
|
||||||
title: '{% trans "Add Test Result" escape %}',
|
title: '{% trans "Add Test Result" escape %}',
|
||||||
|
@ -19,7 +19,7 @@ import part.models
|
|||||||
from common.models import InvenTreeSetting
|
from common.models import InvenTreeSetting
|
||||||
from InvenTree.status_codes import StockHistoryCode, StockStatus
|
from InvenTree.status_codes import StockHistoryCode, StockStatus
|
||||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||||
from part.models import Part
|
from part.models import Part, PartTestTemplate
|
||||||
from stock.models import (
|
from stock.models import (
|
||||||
StockItem,
|
StockItem,
|
||||||
StockItemTestResult,
|
StockItemTestResult,
|
||||||
@ -34,6 +34,7 @@ class StockAPITestCase(InvenTreeAPITestCase):
|
|||||||
fixtures = [
|
fixtures = [
|
||||||
'category',
|
'category',
|
||||||
'part',
|
'part',
|
||||||
|
'test_templates',
|
||||||
'bom',
|
'bom',
|
||||||
'company',
|
'company',
|
||||||
'location',
|
'location',
|
||||||
@ -1559,6 +1560,8 @@ class StockTestResultTest(StockAPITestCase):
|
|||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
n = len(response.data)
|
n = len(response.data)
|
||||||
|
|
||||||
|
# Test upload using test name (legacy method)
|
||||||
|
# Note that a new test template will be created
|
||||||
data = {
|
data = {
|
||||||
'stock_item': 105,
|
'stock_item': 105,
|
||||||
'test': 'Checked Steam Valve',
|
'test': 'Checked Steam Valve',
|
||||||
@ -1569,6 +1572,9 @@ class StockTestResultTest(StockAPITestCase):
|
|||||||
|
|
||||||
response = self.post(url, data, expected_code=201)
|
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)
|
response = self.client.get(url)
|
||||||
self.assertEqual(len(response.data), n + 1)
|
self.assertEqual(len(response.data), n + 1)
|
||||||
|
|
||||||
@ -1581,6 +1587,27 @@ class StockTestResultTest(StockAPITestCase):
|
|||||||
self.assertEqual(test['value'], '150kPa')
|
self.assertEqual(test['value'], '150kPa')
|
||||||
self.assertEqual(test['user'], self.user.pk)
|
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):
|
def test_post_bitmap(self):
|
||||||
"""2021-08-25.
|
"""2021-08-25.
|
||||||
|
|
||||||
@ -1598,14 +1625,15 @@ class StockTestResultTest(StockAPITestCase):
|
|||||||
with open(image_file, 'rb') as bitmap:
|
with open(image_file, 'rb') as bitmap:
|
||||||
data = {
|
data = {
|
||||||
'stock_item': 105,
|
'stock_item': 105,
|
||||||
'test': 'Checked Steam Valve',
|
'test': 'Temperature Test',
|
||||||
'result': False,
|
'result': False,
|
||||||
'value': '150kPa',
|
'value': '550C',
|
||||||
'notes': 'I guess there was just too much pressure?',
|
'notes': 'I guess there was just too much heat?',
|
||||||
'attachment': bitmap,
|
'attachment': bitmap,
|
||||||
}
|
}
|
||||||
|
|
||||||
response = self.client.post(self.get_url(), data)
|
response = self.client.post(self.get_url(), data)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 201)
|
self.assertEqual(response.status_code, 201)
|
||||||
|
|
||||||
# Check that an attachment has been uploaded
|
# Check that an attachment has been uploaded
|
||||||
@ -1619,23 +1647,34 @@ class StockTestResultTest(StockAPITestCase):
|
|||||||
|
|
||||||
url = reverse('api-stock-test-result-list')
|
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)
|
# Create some objects (via the API)
|
||||||
for _ii in range(50):
|
for _ii in range(50):
|
||||||
response = self.post(
|
response = self.post(
|
||||||
url,
|
url,
|
||||||
{
|
{
|
||||||
'stock_item': 1,
|
'stock_item': stock_item.pk,
|
||||||
'test': f'Some test {_ii}',
|
'test': f'Some test {_ii}',
|
||||||
'result': True,
|
'result': True,
|
||||||
'value': 'Test result value',
|
'value': 'Test result value',
|
||||||
},
|
},
|
||||||
expected_code=201,
|
# expected_code=201,
|
||||||
)
|
)
|
||||||
|
|
||||||
tests.append(response.data['pk'])
|
tests.append(response.data['pk'])
|
||||||
|
|
||||||
self.assertEqual(StockItemTestResult.objects.count(), n + 50)
|
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
|
# Attempt a delete without providing items
|
||||||
self.delete(url, {}, expected_code=400)
|
self.delete(url, {}, expected_code=400)
|
||||||
|
|
||||||
@ -1838,6 +1877,7 @@ class StockMetadataAPITest(InvenTreeAPITestCase):
|
|||||||
fixtures = [
|
fixtures = [
|
||||||
'category',
|
'category',
|
||||||
'part',
|
'part',
|
||||||
|
'test_templates',
|
||||||
'bom',
|
'bom',
|
||||||
'company',
|
'company',
|
||||||
'location',
|
'location',
|
||||||
|
@ -133,3 +133,100 @@ class TestScheduledForDeletionMigration(MigratorTestCase):
|
|||||||
|
|
||||||
# All the "scheduled for deletion" items have been removed
|
# All the "scheduled for deletion" items have been removed
|
||||||
self.assertEqual(StockItem.objects.count(), 3)
|
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)
|
||||||
|
@ -12,7 +12,7 @@ from company.models import Company
|
|||||||
from InvenTree.status_codes import StockHistoryCode
|
from InvenTree.status_codes import StockHistoryCode
|
||||||
from InvenTree.unit_test import InvenTreeTestCase
|
from InvenTree.unit_test import InvenTreeTestCase
|
||||||
from order.models import SalesOrder
|
from order.models import SalesOrder
|
||||||
from part.models import Part
|
from part.models import Part, PartTestTemplate
|
||||||
|
|
||||||
from .models import StockItem, StockItemTestResult, StockItemTracking, StockLocation
|
from .models import StockItem, StockItemTestResult, StockItemTracking, StockLocation
|
||||||
|
|
||||||
@ -1086,31 +1086,51 @@ class TestResultTest(StockTestBase):
|
|||||||
|
|
||||||
self.assertEqual(status['total'], 5)
|
self.assertEqual(status['total'], 5)
|
||||||
self.assertEqual(status['passed'], 2)
|
self.assertEqual(status['passed'], 2)
|
||||||
self.assertEqual(status['failed'], 2)
|
self.assertEqual(status['failed'], 1)
|
||||||
|
|
||||||
self.assertFalse(item.passedAllRequiredTests())
|
self.assertFalse(item.passedAllRequiredTests())
|
||||||
|
|
||||||
# Add some new test results to make it pass!
|
# Add some new test results to make it pass!
|
||||||
test = StockItemTestResult.objects.get(pk=12345)
|
test = StockItemTestResult.objects.get(pk=8)
|
||||||
test.result = True
|
test.result = False
|
||||||
test.save()
|
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(
|
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,
|
# Still should be failing at this point,
|
||||||
# as the most recent "apply paint" test was False
|
# as the most recent "apply paint" test was False
|
||||||
self.assertFalse(item.passedAllRequiredTests())
|
self.assertFalse(item.passedAllRequiredTests())
|
||||||
|
|
||||||
|
template = PartTestTemplate.objects.get(pk=2)
|
||||||
|
|
||||||
# Add a new test result against this required test
|
# Add a new test result against this required test
|
||||||
StockItemTestResult.objects.create(
|
StockItemTestResult.objects.create(
|
||||||
stock_item=item,
|
stock_item=item,
|
||||||
test='apply paint',
|
template=template,
|
||||||
date=datetime.datetime(2022, 12, 12),
|
date=datetime.datetime(2022, 12, 12),
|
||||||
result=True,
|
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())
|
self.assertTrue(item.passedAllRequiredTests())
|
||||||
|
|
||||||
def test_duplicate_item_tests(self):
|
def test_duplicate_item_tests(self):
|
||||||
@ -1140,17 +1160,9 @@ class TestResultTest(StockTestBase):
|
|||||||
item.save()
|
item.save()
|
||||||
|
|
||||||
# Do some tests!
|
# Do some tests!
|
||||||
StockItemTestResult.objects.create(
|
item.add_test_result(test_name='Firmware', result=True)
|
||||||
stock_item=item, test='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)
|
||||||
|
|
||||||
StockItemTestResult.objects.create(
|
|
||||||
stock_item=item, test='Paint Color', result=True, value='Red'
|
|
||||||
)
|
|
||||||
|
|
||||||
StockItemTestResult.objects.create(
|
|
||||||
stock_item=item, test='Applied Sticker', result=False
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(item.test_results.count(), 3)
|
self.assertEqual(item.test_results.count(), 3)
|
||||||
self.assertEqual(item.quantity, 50)
|
self.assertEqual(item.quantity, 50)
|
||||||
@ -1163,7 +1175,7 @@ class TestResultTest(StockTestBase):
|
|||||||
self.assertEqual(item.test_results.count(), 3)
|
self.assertEqual(item.test_results.count(), 3)
|
||||||
self.assertEqual(item2.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(item.test_results.count(), 3)
|
||||||
self.assertEqual(item2.test_results.count(), 4)
|
self.assertEqual(item2.test_results.count(), 4)
|
||||||
@ -1172,7 +1184,7 @@ class TestResultTest(StockTestBase):
|
|||||||
item2.serializeStock(1, [100], self.user)
|
item2.serializeStock(1, [100], self.user)
|
||||||
|
|
||||||
# Add a test result to the parent *after* serialization
|
# 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)
|
self.assertEqual(item2.test_results.count(), 5)
|
||||||
|
|
||||||
@ -1201,11 +1213,20 @@ class TestResultTest(StockTestBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Now, create some test results against the sub item
|
# 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
|
# First test is overshadowed by the same test for the parent part
|
||||||
StockItemTestResult.objects.create(
|
StockItemTestResult.objects.create(
|
||||||
stock_item=sub_item,
|
stock_item=sub_item,
|
||||||
test='firmware version',
|
template=template,
|
||||||
date=datetime.datetime.now().date(),
|
date=datetime.datetime.now().date(),
|
||||||
result=True,
|
result=True,
|
||||||
)
|
)
|
||||||
@ -1214,10 +1235,19 @@ class TestResultTest(StockTestBase):
|
|||||||
tests = item.testResultMap(include_installed=True)
|
tests = item.testResultMap(include_installed=True)
|
||||||
self.assertEqual(len(tests), 3)
|
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
|
# Now, add a *unique* test result for the sub item
|
||||||
StockItemTestResult.objects.create(
|
StockItemTestResult.objects.create(
|
||||||
stock_item=sub_item,
|
stock_item=sub_item,
|
||||||
test='some new test',
|
template=template,
|
||||||
date=datetime.datetime.now().date(),
|
date=datetime.datetime.now().date(),
|
||||||
result=False,
|
result=False,
|
||||||
value='abcde',
|
value='abcde',
|
||||||
|
@ -68,6 +68,8 @@ function getModelRenderer(model) {
|
|||||||
return renderPartCategory;
|
return renderPartCategory;
|
||||||
case 'partparametertemplate':
|
case 'partparametertemplate':
|
||||||
return renderPartParameterTemplate;
|
return renderPartParameterTemplate;
|
||||||
|
case 'parttesttemplate':
|
||||||
|
return renderPartTestTemplate;
|
||||||
case 'purchaseorder':
|
case 'purchaseorder':
|
||||||
return renderPurchaseOrder;
|
return renderPurchaseOrder;
|
||||||
case 'salesorder':
|
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
|
// Renderer for "ManufacturerPart" model
|
||||||
function renderManufacturerPart(data, parameters={}) {
|
function renderManufacturerPart(data, parameters={}) {
|
||||||
|
|
||||||
|
@ -2867,6 +2867,18 @@ function loadPartTestTemplateTable(table, options) {
|
|||||||
field: 'test_name',
|
field: 'test_name',
|
||||||
title: '{% trans "Test Name" %}',
|
title: '{% trans "Test Name" %}',
|
||||||
sortable: true,
|
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',
|
field: 'description',
|
||||||
@ -2909,7 +2921,7 @@ function loadPartTestTemplateTable(table, options) {
|
|||||||
} else {
|
} else {
|
||||||
var text = '{% trans "This test is defined for a parent part" %}';
|
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`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1381,7 +1381,11 @@ function formatDate(row) {
|
|||||||
/* Construct set of default fields for a StockItemTestResult */
|
/* Construct set of default fields for a StockItemTestResult */
|
||||||
function stockItemTestResultFields(options={}) {
|
function stockItemTestResultFields(options={}) {
|
||||||
let fields = {
|
let fields = {
|
||||||
test: {},
|
template: {
|
||||||
|
filters: {
|
||||||
|
include_inherited: true,
|
||||||
|
}
|
||||||
|
},
|
||||||
result: {},
|
result: {},
|
||||||
value: {},
|
value: {},
|
||||||
attachment: {},
|
attachment: {},
|
||||||
@ -1393,6 +1397,10 @@ function stockItemTestResultFields(options={}) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (options.part) {
|
||||||
|
fields.template.filters.part = options.part;
|
||||||
|
}
|
||||||
|
|
||||||
if (options.stock_item) {
|
if (options.stock_item) {
|
||||||
fields.stock_item.value = options.stock_item;
|
fields.stock_item.value = options.stock_item;
|
||||||
}
|
}
|
||||||
@ -1412,6 +1420,7 @@ function loadStockTestResultsTable(table, options) {
|
|||||||
|
|
||||||
let params = {
|
let params = {
|
||||||
part: options.part,
|
part: options.part,
|
||||||
|
include_inherited: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
var filters = loadTableFilters(filterKey, params);
|
var filters = loadTableFilters(filterKey, params);
|
||||||
@ -1424,17 +1433,16 @@ function loadStockTestResultsTable(table, options) {
|
|||||||
|
|
||||||
let html = '';
|
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
|
// 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-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) {
|
if (!grouped && row.result != null) {
|
||||||
var pk = row.pk;
|
html += makeEditButton('button-test-edit', row.testId, '{% trans "Edit test result" %}');
|
||||||
html += makeEditButton('button-test-edit', pk, '{% trans "Edit test result" %}');
|
html += makeDeleteButton('button-test-delete', row.testId, '{% trans "Delete test result" %}');
|
||||||
html += makeDeleteButton('button-test-delete', pk, '{% trans "Delete test result" %}');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return wrapButtons(html);
|
return wrapButtons(html);
|
||||||
@ -1532,9 +1540,14 @@ function loadStockTestResultsTable(table, options) {
|
|||||||
],
|
],
|
||||||
onLoadSuccess: function(tableData) {
|
onLoadSuccess: function(tableData) {
|
||||||
|
|
||||||
// Set "parent" for each existing row
|
// Construct an initial dataset based on the returned templates
|
||||||
tableData.forEach(function(item, idx) {
|
let results = tableData.map((template) => {
|
||||||
tableData[idx].parent = parent_node;
|
return {
|
||||||
|
...template,
|
||||||
|
templateId: template.pk,
|
||||||
|
parent: parent_node,
|
||||||
|
results: []
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Once the test template data are loaded, query for test 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,
|
stock_item: options.stock_item,
|
||||||
user_detail: true,
|
user_detail: true,
|
||||||
attachment_detail: true,
|
attachment_detail: true,
|
||||||
|
template_detail: false,
|
||||||
ordering: '-date',
|
ordering: '-date',
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1561,54 +1575,40 @@ function loadStockTestResultsTable(table, options) {
|
|||||||
query_params,
|
query_params,
|
||||||
{
|
{
|
||||||
success: function(data) {
|
success: function(data) {
|
||||||
// Iterate through the returned test data
|
|
||||||
data.forEach(function(item) {
|
|
||||||
|
|
||||||
var match = false;
|
data.sort((a, b) => {
|
||||||
var override = false;
|
return a.pk < b.pk;
|
||||||
|
}).forEach((row) => {
|
||||||
|
let idx = results.findIndex((template) => {
|
||||||
|
return template.templateId == row.template;
|
||||||
|
});
|
||||||
|
|
||||||
// Extract the simplified test key
|
if (idx > -1) {
|
||||||
var key = item.key;
|
|
||||||
|
|
||||||
// Attempt to associate this result with an existing test
|
results[idx].results.push(row);
|
||||||
for (var idx = 0; idx < tableData.length; idx++) {
|
|
||||||
|
|
||||||
var row = tableData[idx];
|
// Check if a test result is already recorded
|
||||||
|
if (results[idx].testId) {
|
||||||
if (key == row.key) {
|
// Push this result into the results array
|
||||||
|
results.push({
|
||||||
item.test_name = row.test_name;
|
...results[idx],
|
||||||
item.test_description = row.description;
|
...row,
|
||||||
item.required = row.required;
|
parent: results[idx].templateId,
|
||||||
|
testId: row.pk,
|
||||||
if (row.result == null) {
|
});
|
||||||
item.parent = parent_node;
|
} else {
|
||||||
tableData[idx] = item;
|
// First result - update the parent row
|
||||||
override = true;
|
results[idx] = {
|
||||||
} else {
|
...row,
|
||||||
item.parent = row.pk;
|
...results[idx],
|
||||||
}
|
testId: row.pk,
|
||||||
|
};
|
||||||
match = true;
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// 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() {
|
$(table).on('click', '.button-test-add', function() {
|
||||||
var button = $(this);
|
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" %}', {
|
constructForm('{% url "api-stock-test-result-list" %}', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
fields: {
|
fields: fields,
|
||||||
test: {
|
|
||||||
value: test_name,
|
|
||||||
},
|
|
||||||
result: {},
|
|
||||||
value: {},
|
|
||||||
attachment: {},
|
|
||||||
notes: {
|
|
||||||
icon: 'fa-sticky-note',
|
|
||||||
},
|
|
||||||
stock_item: {
|
|
||||||
value: options.stock_item,
|
|
||||||
hidden: true,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
title: '{% trans "Add Test Result" %}',
|
title: '{% trans "Add Test Result" %}',
|
||||||
onSuccess: reloadTestTable,
|
onSuccess: reloadTestTable,
|
||||||
});
|
});
|
||||||
@ -1692,11 +1684,9 @@ function loadStockTestResultsTable(table, options) {
|
|||||||
|
|
||||||
var url = `/api/stock/test/${pk}/`;
|
var url = `/api/stock/test/${pk}/`;
|
||||||
|
|
||||||
var row = $(table).bootstrapTable('getRowByUniqueId', pk);
|
|
||||||
|
|
||||||
var html = `
|
var html = `
|
||||||
<div class='alert alert-block alert-danger'>
|
<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>`;
|
</div>`;
|
||||||
|
|
||||||
constructForm(url, {
|
constructForm(url, {
|
||||||
|
@ -20,7 +20,22 @@ Test templates "cascade" down to variant parts: this means that if a master part
|
|||||||
|
|
||||||
The name of the test is a simple string value which defines the name of the test. This test must be unique for a given part (or across a set of part variants).
|
The name of the test is a simple string value which defines the name of the test. This test must be unique for a given part (or across a set of part variants).
|
||||||
|
|
||||||
The test name is used to generate a test "key" which is then used to match against test results associated with individual stock items.
|
#### Test Key
|
||||||
|
|
||||||
|
The test name is used to generate a test "key" which is then used to match against test results associated with individual stock items. The *key* is a simplified string representation of the test name, which consists only of lowercase alphanumeric characters. This key value is automatically generated (based on the test name) whenever the test template is saved.
|
||||||
|
|
||||||
|
The generated test key is intended to be a valid python variable name, and can be used to reference the test in the report generation system.
|
||||||
|
|
||||||
|
##### Examples
|
||||||
|
|
||||||
|
Some examples of generated test key values are provided below:
|
||||||
|
|
||||||
|
| Test Name | Test Key |
|
||||||
|
| --- | --- |
|
||||||
|
| "Firmware Version" | "firmwareversion" |
|
||||||
|
| " My NEW T E sT " | "mynewtest" |
|
||||||
|
| "100 Percent Test"| "_100percenttest" *(note that the leading underscore is added to ensure the key is a valid python variable name)* |
|
||||||
|
| "Test 123" | "test123" |
|
||||||
|
|
||||||
#### Test Description
|
#### Test Description
|
||||||
|
|
||||||
|
@ -14,9 +14,9 @@ The master "Part" record for the stock item can define multiple [test templates]
|
|||||||
|
|
||||||
### Test Result Fields
|
### Test Result Fields
|
||||||
|
|
||||||
#### Test Name
|
#### Test Template
|
||||||
|
|
||||||
The name of the test data is used to associate the test with a test template object.
|
The *template* field links to a [Part Test Template](../part/test.md#part-test-templates) object. Each test result instance must link to a test template.
|
||||||
|
|
||||||
#### Result
|
#### Result
|
||||||
|
|
||||||
|
@ -21,7 +21,7 @@ export function setApiDefaults() {
|
|||||||
const token = useSessionState.getState().token;
|
const token = useSessionState.getState().token;
|
||||||
|
|
||||||
api.defaults.baseURL = host;
|
api.defaults.baseURL = host;
|
||||||
api.defaults.timeout = 1000;
|
api.defaults.timeout = 2500;
|
||||||
|
|
||||||
if (!!token) {
|
if (!!token) {
|
||||||
api.defaults.headers.common['Authorization'] = `Token ${token}`;
|
api.defaults.headers.common['Authorization'] = `Token ${token}`;
|
||||||
|
@ -3,8 +3,18 @@ import { Badge } from '@mantine/core';
|
|||||||
|
|
||||||
import { isTrue } from '../../functions/conversion';
|
import { isTrue } from '../../functions/conversion';
|
||||||
|
|
||||||
export function YesNoButton({ value }: { value: any }) {
|
export function PassFailButton({
|
||||||
|
value,
|
||||||
|
passText,
|
||||||
|
failText
|
||||||
|
}: {
|
||||||
|
value: any;
|
||||||
|
passText?: string;
|
||||||
|
failText?: string;
|
||||||
|
}) {
|
||||||
const v = isTrue(value);
|
const v = isTrue(value);
|
||||||
|
const pass = passText || t`Pass`;
|
||||||
|
const fail = failText || t`Fail`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
@ -13,7 +23,11 @@ export function YesNoButton({ value }: { value: any }) {
|
|||||||
radius="lg"
|
radius="lg"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
{v ? t`Yes` : t`No`}
|
{v ? pass : fail}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function YesNoButton({ value }: { value: any }) {
|
||||||
|
return <PassFailButton value={value} passText={t`Yes`} failText={t`No`} />;
|
||||||
|
}
|
||||||
|
@ -23,7 +23,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
RenderPart,
|
RenderPart,
|
||||||
RenderPartCategory,
|
RenderPartCategory,
|
||||||
RenderPartParameterTemplate
|
RenderPartParameterTemplate,
|
||||||
|
RenderPartTestTemplate
|
||||||
} from './Part';
|
} from './Part';
|
||||||
import { RenderStockItem, RenderStockLocation } from './Stock';
|
import { RenderStockItem, RenderStockLocation } from './Stock';
|
||||||
import { RenderOwner, RenderUser } from './User';
|
import { RenderOwner, RenderUser } from './User';
|
||||||
@ -48,6 +49,7 @@ const RendererLookup: EnumDictionary<
|
|||||||
[ModelType.part]: RenderPart,
|
[ModelType.part]: RenderPart,
|
||||||
[ModelType.partcategory]: RenderPartCategory,
|
[ModelType.partcategory]: RenderPartCategory,
|
||||||
[ModelType.partparametertemplate]: RenderPartParameterTemplate,
|
[ModelType.partparametertemplate]: RenderPartParameterTemplate,
|
||||||
|
[ModelType.parttesttemplate]: RenderPartTestTemplate,
|
||||||
[ModelType.projectcode]: RenderProjectCode,
|
[ModelType.projectcode]: RenderProjectCode,
|
||||||
[ModelType.purchaseorder]: RenderPurchaseOrder,
|
[ModelType.purchaseorder]: RenderPurchaseOrder,
|
||||||
[ModelType.purchaseorderline]: RenderPurchaseOrder,
|
[ModelType.purchaseorderline]: RenderPurchaseOrder,
|
||||||
|
@ -32,6 +32,13 @@ export const ModelInformationDict: ModelDict = {
|
|||||||
url_detail: '/partparametertemplate/:pk/',
|
url_detail: '/partparametertemplate/:pk/',
|
||||||
api_endpoint: ApiEndpoints.part_parameter_template_list
|
api_endpoint: ApiEndpoints.part_parameter_template_list
|
||||||
},
|
},
|
||||||
|
parttesttemplate: {
|
||||||
|
label: t`Part Test Template`,
|
||||||
|
label_multiple: t`Part Test Templates`,
|
||||||
|
url_overview: '/parttesttemplate',
|
||||||
|
url_detail: '/parttesttemplate/:pk/',
|
||||||
|
api_endpoint: ApiEndpoints.part_test_template_list
|
||||||
|
},
|
||||||
supplierpart: {
|
supplierpart: {
|
||||||
label: t`Supplier Part`,
|
label: t`Supplier Part`,
|
||||||
label_multiple: t`Supplier Parts`,
|
label_multiple: t`Supplier Parts`,
|
||||||
|
@ -51,3 +51,16 @@ export function RenderPartParameterTemplate({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RenderPartTestTemplate({
|
||||||
|
instance
|
||||||
|
}: {
|
||||||
|
instance: any;
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<RenderInlineModel
|
||||||
|
primary={instance.test_name}
|
||||||
|
secondary={instance.description}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -80,6 +80,7 @@ export enum ApiEndpoints {
|
|||||||
stock_location_list = 'stock/location/',
|
stock_location_list = 'stock/location/',
|
||||||
stock_location_tree = 'stock/location/tree/',
|
stock_location_tree = 'stock/location/tree/',
|
||||||
stock_attachment_list = 'stock/attachment/',
|
stock_attachment_list = 'stock/attachment/',
|
||||||
|
stock_test_result_list = 'stock/test/',
|
||||||
|
|
||||||
// Order API endpoints
|
// Order API endpoints
|
||||||
purchase_order_list = 'order/po/',
|
purchase_order_list = 'order/po/',
|
||||||
|
@ -7,6 +7,7 @@ export enum ModelType {
|
|||||||
manufacturerpart = 'manufacturerpart',
|
manufacturerpart = 'manufacturerpart',
|
||||||
partcategory = 'partcategory',
|
partcategory = 'partcategory',
|
||||||
partparametertemplate = 'partparametertemplate',
|
partparametertemplate = 'partparametertemplate',
|
||||||
|
parttesttemplate = 'parttesttemplate',
|
||||||
projectcode = 'projectcode',
|
projectcode = 'projectcode',
|
||||||
stockitem = 'stockitem',
|
stockitem = 'stockitem',
|
||||||
stocklocation = 'stocklocation',
|
stocklocation = 'stocklocation',
|
||||||
|
@ -51,6 +51,7 @@ export function useInstance<T = any>({
|
|||||||
|
|
||||||
return api
|
return api
|
||||||
.get(url, {
|
.get(url, {
|
||||||
|
timeout: 10000,
|
||||||
params: params
|
params: params
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
|
@ -19,6 +19,8 @@ export type TableState = {
|
|||||||
activeFilters: TableFilter[];
|
activeFilters: TableFilter[];
|
||||||
setActiveFilters: (filters: TableFilter[]) => void;
|
setActiveFilters: (filters: TableFilter[]) => void;
|
||||||
clearActiveFilters: () => void;
|
clearActiveFilters: () => void;
|
||||||
|
expandedRecords: any[];
|
||||||
|
setExpandedRecords: (records: any[]) => void;
|
||||||
selectedRecords: any[];
|
selectedRecords: any[];
|
||||||
setSelectedRecords: (records: any[]) => void;
|
setSelectedRecords: (records: any[]) => void;
|
||||||
clearSelectedRecords: () => void;
|
clearSelectedRecords: () => void;
|
||||||
@ -59,6 +61,9 @@ export function useTable(tableName: string): TableState {
|
|||||||
setActiveFilters([]);
|
setActiveFilters([]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Array of expanded records
|
||||||
|
const [expandedRecords, setExpandedRecords] = useState<any[]>([]);
|
||||||
|
|
||||||
// Array of selected records
|
// Array of selected records
|
||||||
const [selectedRecords, setSelectedRecords] = useState<any[]>([]);
|
const [selectedRecords, setSelectedRecords] = useState<any[]>([]);
|
||||||
|
|
||||||
@ -81,6 +86,8 @@ export function useTable(tableName: string): TableState {
|
|||||||
activeFilters,
|
activeFilters,
|
||||||
setActiveFilters,
|
setActiveFilters,
|
||||||
clearActiveFilters,
|
clearActiveFilters,
|
||||||
|
expandedRecords,
|
||||||
|
setExpandedRecords,
|
||||||
selectedRecords,
|
selectedRecords,
|
||||||
setSelectedRecords,
|
setSelectedRecords,
|
||||||
clearSelectedRecords,
|
clearSelectedRecords,
|
||||||
|
@ -41,6 +41,7 @@ import { apiUrl } from '../../states/ApiState';
|
|||||||
import { useUserState } from '../../states/UserState';
|
import { useUserState } from '../../states/UserState';
|
||||||
import { AttachmentTable } from '../../tables/general/AttachmentTable';
|
import { AttachmentTable } from '../../tables/general/AttachmentTable';
|
||||||
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
import { StockItemTable } from '../../tables/stock/StockItemTable';
|
||||||
|
import StockItemTestResultTable from '../../tables/stock/StockItemTestResultTable';
|
||||||
|
|
||||||
export default function StockDetail() {
|
export default function StockDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@ -89,7 +90,15 @@ export default function StockDetail() {
|
|||||||
name: 'testdata',
|
name: 'testdata',
|
||||||
label: t`Test Data`,
|
label: t`Test Data`,
|
||||||
icon: <IconChecklist />,
|
icon: <IconChecklist />,
|
||||||
hidden: !stockitem?.part_detail?.trackable
|
hidden: !stockitem?.part_detail?.trackable,
|
||||||
|
content: stockitem?.pk ? (
|
||||||
|
<StockItemTestResultTable
|
||||||
|
itemId={stockitem.pk}
|
||||||
|
partId={stockitem.part}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Skeleton />
|
||||||
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'installed_items',
|
name: 'installed_items',
|
||||||
|
@ -74,7 +74,9 @@ export function ReferenceColumn(): TableColumn {
|
|||||||
export function NoteColumn(): TableColumn {
|
export function NoteColumn(): TableColumn {
|
||||||
return {
|
return {
|
||||||
accessor: 'note',
|
accessor: 'note',
|
||||||
sortable: false
|
sortable: false,
|
||||||
|
title: t`Note`,
|
||||||
|
render: (record: any) => record.note ?? record.notes
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -119,6 +121,15 @@ export function ResponsibleColumn(): TableColumn {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DateColumn(): TableColumn {
|
||||||
|
return {
|
||||||
|
accessor: 'date',
|
||||||
|
sortable: true,
|
||||||
|
title: t`Date`,
|
||||||
|
render: (record: any) => renderDate(record.date)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function TargetDateColumn(): TableColumn {
|
export function TargetDateColumn(): TableColumn {
|
||||||
return {
|
return {
|
||||||
accessor: 'target_date',
|
accessor: 'target_date',
|
||||||
|
@ -74,8 +74,9 @@ export type InvenTreeTableProps<T = any> = {
|
|||||||
tableFilters?: TableFilter[];
|
tableFilters?: TableFilter[];
|
||||||
tableActions?: React.ReactNode[];
|
tableActions?: React.ReactNode[];
|
||||||
printingActions?: any[];
|
printingActions?: any[];
|
||||||
|
rowExpansion?: any;
|
||||||
idAccessor?: string;
|
idAccessor?: string;
|
||||||
dataFormatter?: (data: T) => any;
|
dataFormatter?: (data: any) => any;
|
||||||
rowActions?: (record: T) => RowAction[];
|
rowActions?: (record: T) => RowAction[];
|
||||||
onRowClick?: (record: T, index: number, event: any) => void;
|
onRowClick?: (record: T, index: number, event: any) => void;
|
||||||
};
|
};
|
||||||
@ -223,14 +224,12 @@ export function InvenTreeTable<T = any>({
|
|||||||
hidden: false,
|
hidden: false,
|
||||||
switchable: false,
|
switchable: false,
|
||||||
width: 50,
|
width: 50,
|
||||||
render: function (record: any) {
|
render: (record: any) => (
|
||||||
return (
|
<RowActions
|
||||||
<RowActions
|
actions={tableProps.rowActions?.(record) ?? []}
|
||||||
actions={tableProps.rowActions?.(record) ?? []}
|
disabled={tableState.selectedRecords.length > 0}
|
||||||
disabled={tableState.selectedRecords.length > 0}
|
/>
|
||||||
/>
|
)
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -373,14 +372,11 @@ export function InvenTreeTable<T = any>({
|
|||||||
tableProps.noRecordsText ?? t`No records found`
|
tableProps.noRecordsText ?? t`No records found`
|
||||||
);
|
);
|
||||||
|
|
||||||
let results = [];
|
let results = response.data?.results ?? response.data ?? [];
|
||||||
|
|
||||||
if (props.dataFormatter) {
|
if (props.dataFormatter) {
|
||||||
// Custom data formatter provided
|
// Custom data formatter provided
|
||||||
results = props.dataFormatter(response.data);
|
results = props.dataFormatter(results);
|
||||||
} else {
|
|
||||||
// Extract returned data (accounting for pagination) and ensure it is a list
|
|
||||||
results = response.data?.results ?? response.data ?? [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(results)) {
|
if (!Array.isArray(results)) {
|
||||||
@ -611,6 +607,7 @@ export function InvenTreeTable<T = any>({
|
|||||||
onSelectedRecordsChange={
|
onSelectedRecordsChange={
|
||||||
tableProps.enableSelection ? onSelectedRecordsChange : undefined
|
tableProps.enableSelection ? onSelectedRecordsChange : undefined
|
||||||
}
|
}
|
||||||
|
rowExpansion={tableProps.rowExpansion}
|
||||||
fetching={isFetching}
|
fetching={isFetching}
|
||||||
noRecordsText={missingRecordsText}
|
noRecordsText={missingRecordsText}
|
||||||
records={data}
|
records={data}
|
||||||
|
@ -1,10 +1,14 @@
|
|||||||
import { t } from '@lingui/macro';
|
import { Trans, t } from '@lingui/macro';
|
||||||
|
import { Alert, Badge, Text } from '@mantine/core';
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||||
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
|
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
|
||||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||||
|
import { ModelType } from '../../enums/ModelType';
|
||||||
import { UserRoles } from '../../enums/Roles';
|
import { UserRoles } from '../../enums/Roles';
|
||||||
|
import { getDetailUrl } from '../../functions/urls';
|
||||||
import {
|
import {
|
||||||
useCreateApiFormModal,
|
useCreateApiFormModal,
|
||||||
useDeleteApiFormModal,
|
useDeleteApiFormModal,
|
||||||
@ -22,6 +26,7 @@ import { RowDeleteAction, RowEditAction } from '../RowActions';
|
|||||||
export default function PartTestTemplateTable({ partId }: { partId: number }) {
|
export default function PartTestTemplateTable({ partId }: { partId: number }) {
|
||||||
const table = useTable('part-test-template');
|
const table = useTable('part-test-template');
|
||||||
const user = useUserState();
|
const user = useUserState();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const tableColumns: TableColumn[] = useMemo(() => {
|
const tableColumns: TableColumn[] = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
@ -30,6 +35,15 @@ export default function PartTestTemplateTable({ partId }: { partId: number }) {
|
|||||||
switchable: false,
|
switchable: false,
|
||||||
sortable: true
|
sortable: true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessor: 'results',
|
||||||
|
switchable: true,
|
||||||
|
sortable: true,
|
||||||
|
title: t`Results`,
|
||||||
|
render: (record: any) => {
|
||||||
|
return record.results || <Badge color="blue">{t`No Results`}</Badge>;
|
||||||
|
}
|
||||||
|
},
|
||||||
DescriptionColumn({
|
DescriptionColumn({
|
||||||
switchable: false
|
switchable: false
|
||||||
}),
|
}),
|
||||||
@ -43,7 +57,7 @@ export default function PartTestTemplateTable({ partId }: { partId: number }) {
|
|||||||
accessor: 'requires_attachment'
|
accessor: 'requires_attachment'
|
||||||
})
|
})
|
||||||
];
|
];
|
||||||
}, []);
|
}, [partId]);
|
||||||
|
|
||||||
const tableFilters: TableFilter[] = useMemo(() => {
|
const tableFilters: TableFilter[] = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
@ -58,6 +72,11 @@ export default function PartTestTemplateTable({ partId }: { partId: number }) {
|
|||||||
{
|
{
|
||||||
name: 'requires_attachment',
|
name: 'requires_attachment',
|
||||||
description: t`Show tests that require an attachment`
|
description: t`Show tests that require an attachment`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'include_inherited',
|
||||||
|
label: t`Include Inherited`,
|
||||||
|
description: t`Show tests from inherited templates`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}, []);
|
}, []);
|
||||||
@ -99,13 +118,27 @@ export default function PartTestTemplateTable({ partId }: { partId: number }) {
|
|||||||
url: ApiEndpoints.part_test_template_list,
|
url: ApiEndpoints.part_test_template_list,
|
||||||
pk: selectedTest,
|
pk: selectedTest,
|
||||||
title: t`Delete Test Template`,
|
title: t`Delete Test Template`,
|
||||||
|
preFormContent: (
|
||||||
|
<Alert color="red" title={t`This action cannot be reversed`}>
|
||||||
|
<Text>
|
||||||
|
<Trans>
|
||||||
|
Any tests results associated with this template will be deleted
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
),
|
||||||
onFormSuccess: table.refreshTable
|
onFormSuccess: table.refreshTable
|
||||||
});
|
});
|
||||||
|
|
||||||
const rowActions = useCallback(
|
const rowActions = useCallback(
|
||||||
(record: any) => {
|
(record: any) => {
|
||||||
let can_edit = user.hasChangeRole(UserRoles.part);
|
const can_edit = user.hasChangeRole(UserRoles.part);
|
||||||
let can_delete = user.hasDeleteRole(UserRoles.part);
|
const can_delete = user.hasDeleteRole(UserRoles.part);
|
||||||
|
|
||||||
|
if (record.part != partId) {
|
||||||
|
// No actions, as this test is defined for a parent part
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
RowEditAction({
|
RowEditAction({
|
||||||
@ -124,7 +157,7 @@ export default function PartTestTemplateTable({ partId }: { partId: number }) {
|
|||||||
})
|
})
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
[user]
|
[user, partId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const tableActions = useMemo(() => {
|
const tableActions = useMemo(() => {
|
||||||
@ -150,11 +183,18 @@ export default function PartTestTemplateTable({ partId }: { partId: number }) {
|
|||||||
columns={tableColumns}
|
columns={tableColumns}
|
||||||
props={{
|
props={{
|
||||||
params: {
|
params: {
|
||||||
part: partId
|
part: partId,
|
||||||
|
part_detail: true
|
||||||
},
|
},
|
||||||
tableFilters: tableFilters,
|
tableFilters: tableFilters,
|
||||||
tableActions: tableActions,
|
tableActions: tableActions,
|
||||||
rowActions: rowActions
|
rowActions: rowActions,
|
||||||
|
onRowClick: (row) => {
|
||||||
|
if (row.part && row.part != partId) {
|
||||||
|
// This test is defined for a different part
|
||||||
|
navigate(getDetailUrl(ModelType.part, row.part));
|
||||||
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
@ -67,8 +67,8 @@ export default function CurrencyTable() {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
props={{
|
props={{
|
||||||
tableActions: tableActions,
|
tableActions: tableActions,
|
||||||
dataFormatter: (data) => {
|
dataFormatter: (data: any) => {
|
||||||
let rates = data?.exchange_rates ?? {};
|
let rates = data.exchange_rates ?? {};
|
||||||
|
|
||||||
return Object.entries(rates).map(([currency, rate]) => {
|
return Object.entries(rates).map(([currency, rate]) => {
|
||||||
return {
|
return {
|
||||||
|
@ -338,7 +338,7 @@ export function StockItemTable({ params = {} }: { params?: any }) {
|
|||||||
columns={tableColumns}
|
columns={tableColumns}
|
||||||
props={{
|
props={{
|
||||||
enableDownload: true,
|
enableDownload: true,
|
||||||
enableSelection: true,
|
enableSelection: false,
|
||||||
tableFilters: tableFilters,
|
tableFilters: tableFilters,
|
||||||
onRowClick: (record) =>
|
onRowClick: (record) =>
|
||||||
navigate(getDetailUrl(ModelType.stockitem, record.pk)),
|
navigate(getDetailUrl(ModelType.stockitem, record.pk)),
|
||||||
|
428
src/frontend/src/tables/stock/StockItemTestResultTable.tsx
Normal file
428
src/frontend/src/tables/stock/StockItemTestResultTable.tsx
Normal file
@ -0,0 +1,428 @@
|
|||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { Badge, Group, Text, Tooltip } from '@mantine/core';
|
||||||
|
import { showNotification } from '@mantine/notifications';
|
||||||
|
import {
|
||||||
|
IconCircleCheck,
|
||||||
|
IconCirclePlus,
|
||||||
|
IconInfoCircle
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { DataTable } from 'mantine-datatable';
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { api } from '../../App';
|
||||||
|
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||||
|
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
|
||||||
|
import { AttachmentLink } from '../../components/items/AttachmentLink';
|
||||||
|
import { PassFailButton } from '../../components/items/YesNoButton';
|
||||||
|
import { RenderUser } from '../../components/render/User';
|
||||||
|
import { renderDate } from '../../defaults/formatters';
|
||||||
|
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||||
|
import { UserRoles } from '../../enums/Roles';
|
||||||
|
import {
|
||||||
|
useCreateApiFormModal,
|
||||||
|
useDeleteApiFormModal,
|
||||||
|
useEditApiFormModal
|
||||||
|
} from '../../hooks/UseForm';
|
||||||
|
import { useTable } from '../../hooks/UseTable';
|
||||||
|
import { apiUrl } from '../../states/ApiState';
|
||||||
|
import { useUserState } from '../../states/UserState';
|
||||||
|
import { TableColumn } from '../Column';
|
||||||
|
import { DescriptionColumn, NoteColumn } from '../ColumnRenderers';
|
||||||
|
import { TableFilter } from '../Filter';
|
||||||
|
import { InvenTreeTable } from '../InvenTreeTable';
|
||||||
|
import { RowActions, RowDeleteAction, RowEditAction } from '../RowActions';
|
||||||
|
|
||||||
|
export default function StockItemTestResultTable({
|
||||||
|
partId,
|
||||||
|
itemId
|
||||||
|
}: {
|
||||||
|
partId: number;
|
||||||
|
itemId: number;
|
||||||
|
}) {
|
||||||
|
const user = useUserState();
|
||||||
|
const table = useTable('stocktests');
|
||||||
|
|
||||||
|
// Fetch the test templates required for this stock item
|
||||||
|
const { data: testTemplates } = useQuery({
|
||||||
|
queryKey: ['stocktesttemplates', partId, itemId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!partId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return api
|
||||||
|
.get(apiUrl(ApiEndpoints.part_test_template_list), {
|
||||||
|
params: {
|
||||||
|
part: partId,
|
||||||
|
include_inherited: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((response) => response.data)
|
||||||
|
.catch((_error) => []);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
table.refreshTable();
|
||||||
|
}, [testTemplates]);
|
||||||
|
|
||||||
|
// Format the test results based on the returned data
|
||||||
|
const formatRecords = useCallback(
|
||||||
|
(records: any[]): any[] => {
|
||||||
|
// Construct a list of test templates
|
||||||
|
let results = testTemplates.map((template: any) => {
|
||||||
|
return {
|
||||||
|
...template,
|
||||||
|
templateId: template.pk,
|
||||||
|
results: []
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// If any of the tests results point to templates which we do not have, add them in
|
||||||
|
records.forEach((record) => {
|
||||||
|
if (!results.find((r: any) => r.templateId == record.template)) {
|
||||||
|
results.push({
|
||||||
|
...record.template_detail,
|
||||||
|
templateId: record.template,
|
||||||
|
results: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Iterate through the returned records
|
||||||
|
// Note that the results are sorted by oldest first,
|
||||||
|
// to ensure that the most recent result is displayed "on top"
|
||||||
|
records
|
||||||
|
.sort((a: any, b: any) => {
|
||||||
|
return a.pk > b.pk ? 1 : -1;
|
||||||
|
})
|
||||||
|
.forEach((record) => {
|
||||||
|
// Find matching template
|
||||||
|
let idx = results.findIndex(
|
||||||
|
(r: any) => r.templateId == record.template
|
||||||
|
);
|
||||||
|
if (idx >= 0) {
|
||||||
|
results[idx] = {
|
||||||
|
...results[idx],
|
||||||
|
...record
|
||||||
|
};
|
||||||
|
|
||||||
|
results[idx].results.push(record);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return results;
|
||||||
|
},
|
||||||
|
[partId, itemId, testTemplates]
|
||||||
|
);
|
||||||
|
|
||||||
|
const tableColumns: TableColumn[] = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
accessor: 'test',
|
||||||
|
title: t`Test`,
|
||||||
|
switchable: false,
|
||||||
|
sortable: true,
|
||||||
|
render: (record: any) => {
|
||||||
|
let required = record.required ?? record.template_detail?.required;
|
||||||
|
let installed =
|
||||||
|
record.stock_item != undefined && record.stock_item != itemId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group position="apart">
|
||||||
|
<Text italic={installed} fw={required && 700}>
|
||||||
|
{!record.templateId && '- '}
|
||||||
|
{record.test_name ?? record.template_detail?.test_name}
|
||||||
|
</Text>
|
||||||
|
<Group position="right">
|
||||||
|
{record.results && record.results.length > 1 && (
|
||||||
|
<Tooltip label={t`Test Results`}>
|
||||||
|
<Badge color="lightblue" variant="filled">
|
||||||
|
{record.results.length}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{installed && (
|
||||||
|
<Tooltip label={t`Test result for installed stock item`}>
|
||||||
|
<IconInfoCircle size={16} color="blue" />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessor: 'result',
|
||||||
|
title: t`Result`,
|
||||||
|
switchable: false,
|
||||||
|
sortable: true,
|
||||||
|
render: (record: any) => {
|
||||||
|
if (record.result === undefined) {
|
||||||
|
return (
|
||||||
|
<Badge color="lightblue" variant="filled">{t`No Result`}</Badge>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return <PassFailButton value={record.result} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
DescriptionColumn({
|
||||||
|
accessor: 'description'
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
accessor: 'value',
|
||||||
|
title: t`Value`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessor: 'attachment',
|
||||||
|
title: t`Attachment`,
|
||||||
|
render: (record: any) =>
|
||||||
|
record.attachment && <AttachmentLink attachment={record.attachment} />
|
||||||
|
},
|
||||||
|
NoteColumn(),
|
||||||
|
{
|
||||||
|
accessor: 'date',
|
||||||
|
sortable: true,
|
||||||
|
title: t`Date`,
|
||||||
|
render: (record: any) => {
|
||||||
|
return (
|
||||||
|
<Group position="apart">
|
||||||
|
{renderDate(record.date)}
|
||||||
|
{record.user_detail && (
|
||||||
|
<RenderUser instance={record.user_detail} />
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}, [itemId]);
|
||||||
|
|
||||||
|
const resultFields: ApiFormFieldSet = useMemo(() => {
|
||||||
|
return {
|
||||||
|
template: {
|
||||||
|
filters: {
|
||||||
|
include_inherited: true,
|
||||||
|
part: partId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
result: {},
|
||||||
|
value: {},
|
||||||
|
attachment: {},
|
||||||
|
notes: {},
|
||||||
|
stock_item: {
|
||||||
|
value: itemId,
|
||||||
|
hidden: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [partId, itemId]);
|
||||||
|
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<number | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const newTestModal = useCreateApiFormModal({
|
||||||
|
url: ApiEndpoints.stock_test_result_list,
|
||||||
|
fields: resultFields,
|
||||||
|
initialData: {
|
||||||
|
template: selectedTemplate,
|
||||||
|
result: true
|
||||||
|
},
|
||||||
|
title: t`Add Test Result`,
|
||||||
|
onFormSuccess: () => table.refreshTable(),
|
||||||
|
successMessage: t`Test result added`
|
||||||
|
});
|
||||||
|
|
||||||
|
const [selectedTest, setSelectedTest] = useState<number | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const editTestModal = useEditApiFormModal({
|
||||||
|
url: ApiEndpoints.stock_test_result_list,
|
||||||
|
pk: selectedTest,
|
||||||
|
fields: resultFields,
|
||||||
|
title: t`Edit Test Result`,
|
||||||
|
onFormSuccess: () => table.refreshTable(),
|
||||||
|
successMessage: t`Test result updated`
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteTestModal = useDeleteApiFormModal({
|
||||||
|
url: ApiEndpoints.stock_test_result_list,
|
||||||
|
pk: selectedTest,
|
||||||
|
title: t`Delete Test Result`,
|
||||||
|
onFormSuccess: () => table.refreshTable(),
|
||||||
|
successMessage: t`Test result deleted`
|
||||||
|
});
|
||||||
|
|
||||||
|
const passTest = useCallback(
|
||||||
|
(templateId: number) => {
|
||||||
|
api
|
||||||
|
.post(apiUrl(ApiEndpoints.stock_test_result_list), {
|
||||||
|
template: templateId,
|
||||||
|
stock_item: itemId,
|
||||||
|
result: true
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
table.refreshTable();
|
||||||
|
showNotification({
|
||||||
|
title: t`Test Passed`,
|
||||||
|
message: t`Test result has been recorded`,
|
||||||
|
color: 'green'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[itemId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const rowActions = useCallback(
|
||||||
|
(record: any) => {
|
||||||
|
if (record.stock_item != undefined && record.stock_item != itemId) {
|
||||||
|
// Test results for other stock items cannot be edited
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: t`Pass Test`,
|
||||||
|
color: 'green',
|
||||||
|
icon: <IconCircleCheck />,
|
||||||
|
hidden:
|
||||||
|
!record.templateId ||
|
||||||
|
record?.requires_attachment ||
|
||||||
|
record?.requires_value ||
|
||||||
|
record.result,
|
||||||
|
onClick: () => passTest(record.templateId)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t`Add`,
|
||||||
|
tooltip: t`Add Test Result`,
|
||||||
|
color: 'green',
|
||||||
|
icon: <IconCirclePlus />,
|
||||||
|
hidden: !user.hasAddRole(UserRoles.stock) || !record.templateId,
|
||||||
|
onClick: () => {
|
||||||
|
setSelectedTemplate(record.templateId);
|
||||||
|
newTestModal.open();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
RowEditAction({
|
||||||
|
tooltip: t`Edit Test Result`,
|
||||||
|
hidden:
|
||||||
|
!user.hasChangeRole(UserRoles.stock) || !record.template_detail,
|
||||||
|
onClick: () => {
|
||||||
|
setSelectedTest(record.pk);
|
||||||
|
editTestModal.open();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
RowDeleteAction({
|
||||||
|
tooltip: t`Delete Test Result`,
|
||||||
|
hidden:
|
||||||
|
!user.hasDeleteRole(UserRoles.stock) || !record.template_detail,
|
||||||
|
onClick: () => {
|
||||||
|
setSelectedTest(record.pk);
|
||||||
|
deleteTestModal.open();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
];
|
||||||
|
},
|
||||||
|
[user, itemId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const tableFilters: TableFilter[] = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'required',
|
||||||
|
label: t`Required`,
|
||||||
|
description: t`Show results for required tests`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'include_installed',
|
||||||
|
label: t`Include Installed`,
|
||||||
|
description: t`Show results for installed stock items`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'result',
|
||||||
|
label: t`Passed`,
|
||||||
|
description: t`Show only passed tests`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const tableActions = useMemo(() => {
|
||||||
|
return [
|
||||||
|
<AddItemButton
|
||||||
|
tooltip={t`Add Test Result`}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedTemplate(undefined);
|
||||||
|
newTestModal.open();
|
||||||
|
}}
|
||||||
|
hidden={!user.hasAddRole(UserRoles.stock)}
|
||||||
|
/>
|
||||||
|
];
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
// Row expansion controller
|
||||||
|
const rowExpansion: any = useMemo(() => {
|
||||||
|
const cols: any = [
|
||||||
|
...tableColumns,
|
||||||
|
{
|
||||||
|
accessor: 'actions',
|
||||||
|
title: ' ',
|
||||||
|
hidden: false,
|
||||||
|
switchable: false,
|
||||||
|
width: 50,
|
||||||
|
render: (record: any) => (
|
||||||
|
<RowActions actions={rowActions(record) ?? []} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowMultiple: true,
|
||||||
|
content: ({ record }: { record: any }) => {
|
||||||
|
if (!record || !record.results || record.results.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = record?.results ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
key={record.pk}
|
||||||
|
noHeader
|
||||||
|
columns={cols}
|
||||||
|
records={results.slice(0, -1)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{newTestModal.modal}
|
||||||
|
{editTestModal.modal}
|
||||||
|
{deleteTestModal.modal}
|
||||||
|
<InvenTreeTable
|
||||||
|
url={apiUrl(ApiEndpoints.stock_test_result_list)}
|
||||||
|
tableState={table}
|
||||||
|
columns={tableColumns}
|
||||||
|
props={{
|
||||||
|
dataFormatter: formatRecords,
|
||||||
|
enablePagination: false,
|
||||||
|
tableActions: tableActions,
|
||||||
|
tableFilters: tableFilters,
|
||||||
|
rowActions: rowActions,
|
||||||
|
rowExpansion: rowExpansion,
|
||||||
|
params: {
|
||||||
|
stock_item: itemId,
|
||||||
|
user_detail: true,
|
||||||
|
attachment_detail: true,
|
||||||
|
template_detail: true
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
8
tasks.py
8
tasks.py
@ -105,12 +105,7 @@ def content_excludes(
|
|||||||
excludes.append('socialaccount.socialapp')
|
excludes.append('socialaccount.socialapp')
|
||||||
excludes.append('socialaccount.socialtoken')
|
excludes.append('socialaccount.socialtoken')
|
||||||
|
|
||||||
output = ''
|
return ' '.join([f'--exclude {e}' for e in excludes])
|
||||||
|
|
||||||
for e in excludes:
|
|
||||||
output += f'--exclude {e} '
|
|
||||||
|
|
||||||
return output
|
|
||||||
|
|
||||||
|
|
||||||
def localDir() -> Path:
|
def localDir() -> Path:
|
||||||
@ -370,6 +365,7 @@ def migrate(c):
|
|||||||
print('========================================')
|
print('========================================')
|
||||||
|
|
||||||
# Run custom management command which wraps migrations in "maintenance mode"
|
# Run custom management command which wraps migrations in "maintenance mode"
|
||||||
|
manage(c, 'makemigrations')
|
||||||
manage(c, 'runmigrations', pty=True)
|
manage(c, 'runmigrations', pty=True)
|
||||||
manage(c, 'migrate --run-syncdb')
|
manage(c, 'migrate --run-syncdb')
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user