mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-18 13:05:42 +00:00
[WIP] Test result table (#6430)
* Add basic table for stock item test results * Improve custom data formatter callback * Custom data formatter for returned results * Update YesNoButton functionality - Add PassFailButton with custom text * Enhancements for stock item test result table - Render all data * Add placeholder row actions * Fix table link * Add option to filter parttesttemplate table by "inherited" * Navigate through to parent part * Update PartTestTemplate model - Save 'key' value to database - Update whenever model is saved - Custom data migration * Custom migration step in tasks.py - Add custom management command - Wraps migration step in maintenance mode * Improve uniqueness validation for PartTestTemplate * Add 'template' field to StockItemTestResult - Links to a PartTestTemplate instance - Add migrations to link existing PartTestTemplates * Add "results" count to PartTestTemplate API - Include in rendered tables * Add 'results' column to test result table - Allow filtering too * Update serializer for StockItemTestResult - Include template information - Update CUI and PUI tables * Control template_detail field with query params * Update ref in api_version.py * Update data migration - Ensure new template is created for top level assembly * Fix admin integration * Update StockItemTestResult table - Remove 'test' field - Make 'template' field non-nullable - Previous data migrations should have accounted for this * Implement "legacy" API support - Create test result by providing test name - Lookup existing template * PUI: Cleanup table * Update tasks.py - Exclude temporary settings when exporting data * Fix unique validation check * Remove duplicate code * CUI: Fix data rendering * More refactoring of PUI table * More fixes for PUI table * Get row expansion working (kinda) * Improve rendering of subtable * More PUI updates: - Edit existing results - Add new results * allow delete of test result * Fix typo * Updates for admin integration * Unit tests for stock migrations * Added migration test for PartTestTemplate * Fix for AttachmentTable - Rebuild actions when permissions are recalculated * Update test fixtures * Add ModelType information * Fix TableState * Fix dataFormatter type def * Improve table rendering * Correctly filter "edit" and "delete" buttons * Loosen requirements for dataFormatter * Fixtures for report tests * Better API filtering for StocokItemTestResult list - Add Filter class - Add option for filtering against legacy "name" data * Cleanup API filter * Fix unit tests * Further unit test fixes * Include test results for installed stock items * Improve rendering of test result table * Fix filtering for getTestResults * More unit test fixes * Fix more unit tests * FIx part unit test * More fixes * More unit test fixes * Rebuild stock item trees when merging * Helper function for adding a test result to a stock item * Set init fix * Code cleanup * Cleanup unused variables * Add docs and more unit tests * Update build unit test
This commit is contained in:
@ -1,12 +1,17 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 168
|
||||
INVENTREE_API_VERSION = 169
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v168 -> 2024-02-07 : https://github.com/inventree/InvenTree/pull/4824
|
||||
v169 -> 2024-02-14 : https://github.com/inventree/InvenTree/pull/6430
|
||||
- Adds 'key' field to PartTestTemplate API endpoint
|
||||
- Adds annotated 'results' field to PartTestTemplate API endpoint
|
||||
- Adds 'template' field to StockItemTestResult API endpoint
|
||||
|
||||
v168 -> 2024-02-14 : https://github.com/inventree/InvenTree/pull/4824
|
||||
- Adds machine CRUD API endpoints
|
||||
- Adds machine settings API endpoints
|
||||
- Adds machine restart API endpoint
|
||||
|
@ -76,16 +76,23 @@ def extract_int(reference, clip=0x7FFFFFFF, allow_negative=False):
|
||||
return ref_int
|
||||
|
||||
|
||||
def generateTestKey(test_name):
|
||||
def generateTestKey(test_name: str) -> str:
|
||||
"""Generate a test 'key' for a given test name. This must not have illegal chars as it will be used for dict lookup in a template.
|
||||
|
||||
Tests must be named such that they will have unique keys.
|
||||
"""
|
||||
if test_name is None:
|
||||
test_name = ''
|
||||
|
||||
key = test_name.strip().lower()
|
||||
key = key.replace(' ', '')
|
||||
|
||||
# Remove any characters that cannot be used to represent a variable
|
||||
key = re.sub(r'[^a-zA-Z0-9]', '', key)
|
||||
key = re.sub(r'[^a-zA-Z0-9_]', '', key)
|
||||
|
||||
# If the key starts with a digit, prefix with an underscore
|
||||
if key[0].isdigit():
|
||||
key = '_' + key
|
||||
|
||||
return key
|
||||
|
||||
|
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(InvenTreeSetting, models)
|
||||
|
||||
def test_test_key(self):
|
||||
"""Test for the generateTestKey function."""
|
||||
tests = {
|
||||
' Hello World ': 'helloworld',
|
||||
' MY NEW TEST KEY ': 'mynewtestkey',
|
||||
' 1234 5678': '_12345678',
|
||||
' 100 percenT': '_100percent',
|
||||
' MY_NEW_TEST': 'my_new_test',
|
||||
' 100_new_tests': '_100_new_tests',
|
||||
}
|
||||
|
||||
for name, key in tests.items():
|
||||
self.assertEqual(helpers.generateTestKey(name), key)
|
||||
|
||||
|
||||
class TestQuoteWrap(TestCase):
|
||||
"""Tests for string wrapping."""
|
||||
|
@ -655,9 +655,10 @@ class BuildTest(BuildTestBase):
|
||||
# let's complete the required test and see if it could be saved
|
||||
StockItemTestResult.objects.create(
|
||||
stock_item=self.stockitem_with_required_test,
|
||||
test=self.test_template_required.test_name,
|
||||
template=self.test_template_required,
|
||||
result=True
|
||||
)
|
||||
|
||||
self.build_w_tests_trackable.complete_build_output(self.stockitem_with_required_test, None)
|
||||
|
||||
# let's see if a non required test could be saved
|
||||
|
@ -363,6 +363,7 @@ class PartTestTemplateAdmin(admin.ModelAdmin):
|
||||
"""Admin class for the PartTestTemplate model."""
|
||||
|
||||
list_display = ('part', 'test_name', 'required')
|
||||
readonly_fields = ['key']
|
||||
|
||||
autocomplete_fields = ('part',)
|
||||
|
||||
|
@ -389,29 +389,53 @@ class PartTestTemplateFilter(rest_filters.FilterSet):
|
||||
|
||||
Note that for the 'part' field, we also include any parts "above" the specified part.
|
||||
"""
|
||||
variants = part.get_ancestors(include_self=True)
|
||||
return queryset.filter(part__in=variants)
|
||||
include_inherited = str2bool(
|
||||
self.request.query_params.get('include_inherited', True)
|
||||
)
|
||||
|
||||
if include_inherited:
|
||||
return queryset.filter(part__in=part.get_ancestors(include_self=True))
|
||||
else:
|
||||
return queryset.filter(part=part)
|
||||
|
||||
|
||||
class PartTestTemplateDetail(RetrieveUpdateDestroyAPI):
|
||||
class PartTestTemplateMixin:
|
||||
"""Mixin class for the PartTestTemplate API endpoints."""
|
||||
|
||||
queryset = PartTestTemplate.objects.all()
|
||||
serializer_class = part_serializers.PartTestTemplateSerializer
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
"""Return an annotated queryset for the PartTestTemplateDetail endpoints."""
|
||||
queryset = super().get_queryset(*args, **kwargs)
|
||||
queryset = part_serializers.PartTestTemplateSerializer.annotate_queryset(
|
||||
queryset
|
||||
)
|
||||
return queryset
|
||||
|
||||
|
||||
class PartTestTemplateDetail(PartTestTemplateMixin, RetrieveUpdateDestroyAPI):
|
||||
"""Detail endpoint for PartTestTemplate model."""
|
||||
|
||||
queryset = PartTestTemplate.objects.all()
|
||||
serializer_class = part_serializers.PartTestTemplateSerializer
|
||||
pass
|
||||
|
||||
|
||||
class PartTestTemplateList(ListCreateAPI):
|
||||
class PartTestTemplateList(PartTestTemplateMixin, ListCreateAPI):
|
||||
"""API endpoint for listing (and creating) a PartTestTemplate."""
|
||||
|
||||
queryset = PartTestTemplate.objects.all()
|
||||
serializer_class = part_serializers.PartTestTemplateSerializer
|
||||
filterset_class = PartTestTemplateFilter
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
search_fields = ['test_name', 'description']
|
||||
|
||||
ordering_fields = ['test_name', 'required', 'requires_value', 'requires_attachment']
|
||||
ordering_fields = [
|
||||
'test_name',
|
||||
'required',
|
||||
'requires_value',
|
||||
'requires_attachment',
|
||||
'results',
|
||||
]
|
||||
|
||||
ordering = 'test_name'
|
||||
|
||||
|
@ -4,30 +4,35 @@
|
||||
fields:
|
||||
part: 10000
|
||||
test_name: Test strength of chair
|
||||
key: 'teststrengthofchair'
|
||||
|
||||
- model: part.parttesttemplate
|
||||
pk: 2
|
||||
fields:
|
||||
part: 10000
|
||||
test_name: Apply paint
|
||||
key: 'applypaint'
|
||||
|
||||
- model: part.parttesttemplate
|
||||
pk: 3
|
||||
fields:
|
||||
part: 10000
|
||||
test_name: Sew cushion
|
||||
key: 'sewcushion'
|
||||
|
||||
- model: part.parttesttemplate
|
||||
pk: 4
|
||||
fields:
|
||||
part: 10000
|
||||
test_name: Attach legs
|
||||
key: 'attachlegs'
|
||||
|
||||
- model: part.parttesttemplate
|
||||
pk: 5
|
||||
fields:
|
||||
part: 10000
|
||||
test_name: Record weight
|
||||
key: 'recordweight'
|
||||
required: false
|
||||
|
||||
# Add some tests for one of the variants
|
||||
@ -35,12 +40,30 @@
|
||||
pk: 6
|
||||
fields:
|
||||
part: 10003
|
||||
test_name: Check that chair is green
|
||||
test_name: Check chair is green
|
||||
key: 'checkchairisgreen'
|
||||
required: true
|
||||
|
||||
- model: part.parttesttemplate
|
||||
pk: 7
|
||||
pk: 8
|
||||
fields:
|
||||
part: 10004
|
||||
test_name: Check that chair is especially green
|
||||
part: 25
|
||||
test_name: 'Temperature Test'
|
||||
key: 'temperaturetest'
|
||||
required: False
|
||||
|
||||
- model: part.parttesttemplate
|
||||
pk: 9
|
||||
fields:
|
||||
part: 25
|
||||
test_name: 'Settings Checksum'
|
||||
key: 'settingschecksum'
|
||||
required: False
|
||||
|
||||
- model: part.parttesttemplate
|
||||
pk: 10
|
||||
fields:
|
||||
part: 25
|
||||
test_name: 'Firmware Version'
|
||||
key: 'firmwareversion'
|
||||
required: False
|
||||
|
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).
|
||||
"""
|
||||
|
||||
def __str__(self):
|
||||
"""Format a string representation of this PartTestTemplate."""
|
||||
return ' | '.join([self.part.name, self.test_name])
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return the list API endpoint URL associated with the PartTestTemplate model."""
|
||||
@ -3408,6 +3412,8 @@ class PartTestTemplate(InvenTree.models.InvenTreeMetadataModel):
|
||||
"""Clean fields for the PartTestTemplate model."""
|
||||
self.test_name = self.test_name.strip()
|
||||
|
||||
self.key = helpers.generateTestKey(self.test_name)
|
||||
|
||||
self.validate_unique()
|
||||
super().clean()
|
||||
|
||||
@ -3418,30 +3424,18 @@ class PartTestTemplate(InvenTree.models.InvenTreeMetadataModel):
|
||||
'part': _('Test templates can only be created for trackable parts')
|
||||
})
|
||||
|
||||
# Get a list of all tests "above" this one
|
||||
# Check that this test is unique within the part tree
|
||||
tests = PartTestTemplate.objects.filter(
|
||||
part__in=self.part.get_ancestors(include_self=True)
|
||||
)
|
||||
key=self.key, part__tree_id=self.part.tree_id
|
||||
).exclude(pk=self.pk)
|
||||
|
||||
# If this item is already in the database, exclude it from comparison!
|
||||
if self.pk is not None:
|
||||
tests = tests.exclude(pk=self.pk)
|
||||
|
||||
key = self.key
|
||||
|
||||
for test in tests:
|
||||
if test.key == key:
|
||||
raise ValidationError({
|
||||
'test_name': _('Test with this name already exists for this part')
|
||||
})
|
||||
if tests.exists():
|
||||
raise ValidationError({
|
||||
'test_name': _('Test with this name already exists for this part')
|
||||
})
|
||||
|
||||
super().validate_unique(exclude)
|
||||
|
||||
@property
|
||||
def key(self):
|
||||
"""Generate a key for this test."""
|
||||
return helpers.generateTestKey(self.test_name)
|
||||
|
||||
part = models.ForeignKey(
|
||||
Part,
|
||||
on_delete=models.CASCADE,
|
||||
@ -3457,6 +3451,13 @@ class PartTestTemplate(InvenTree.models.InvenTreeMetadataModel):
|
||||
help_text=_('Enter a name for the test'),
|
||||
)
|
||||
|
||||
key = models.CharField(
|
||||
blank=True,
|
||||
max_length=100,
|
||||
verbose_name=_('Test Key'),
|
||||
help_text=_('Simplified key for the test'),
|
||||
)
|
||||
|
||||
description = models.CharField(
|
||||
blank=False,
|
||||
null=True,
|
||||
|
@ -156,9 +156,20 @@ class PartTestTemplateSerializer(InvenTree.serializers.InvenTreeModelSerializer)
|
||||
'required',
|
||||
'requires_value',
|
||||
'requires_attachment',
|
||||
'results',
|
||||
]
|
||||
|
||||
key = serializers.CharField(read_only=True)
|
||||
results = serializers.IntegerField(
|
||||
label=_('Results'),
|
||||
help_text=_('Number of results recorded against this template'),
|
||||
read_only=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def annotate_queryset(queryset):
|
||||
"""Custom query annotations for the PartTestTemplate serializer."""
|
||||
return queryset.annotate(results=SubqueryCount('test_results'))
|
||||
|
||||
|
||||
class PartSalePriceSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
|
@ -806,14 +806,14 @@ class PartAPITest(PartAPITestBase):
|
||||
response = self.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 7)
|
||||
self.assertEqual(len(response.data), 9)
|
||||
|
||||
# Request for a particular part
|
||||
response = self.get(url, data={'part': 10000})
|
||||
self.assertEqual(len(response.data), 5)
|
||||
|
||||
response = self.get(url, data={'part': 10004})
|
||||
self.assertEqual(len(response.data), 7)
|
||||
self.assertEqual(len(response.data), 6)
|
||||
|
||||
# Try to post a new object (missing description)
|
||||
response = self.post(
|
||||
|
@ -229,3 +229,47 @@ class TestPartParameterTemplateMigration(MigratorTestCase):
|
||||
|
||||
self.assertEqual(template.choices, '')
|
||||
self.assertEqual(template.checkbox, False)
|
||||
|
||||
|
||||
class TestPartTestParameterMigration(MigratorTestCase):
|
||||
"""Unit tests for the PartTestTemplate model migrations."""
|
||||
|
||||
migrate_from = ('part', '0119_auto_20231120_0457')
|
||||
migrate_to = ('part', '0121_auto_20240207_0344')
|
||||
|
||||
test_keys = {
|
||||
'atest': 'A test',
|
||||
'someresult': 'Some result',
|
||||
'anotherresult': 'Another result',
|
||||
}
|
||||
|
||||
def prepare(self):
|
||||
"""Setup initial database state."""
|
||||
Part = self.old_state.apps.get_model('part', 'part')
|
||||
PartTestTemplate = self.old_state.apps.get_model('part', 'parttesttemplate')
|
||||
|
||||
# Create a part
|
||||
p = Part.objects.create(
|
||||
name='Test Part',
|
||||
description='A test part',
|
||||
level=0,
|
||||
lft=0,
|
||||
rght=0,
|
||||
tree_id=0,
|
||||
)
|
||||
|
||||
# Create some test templates
|
||||
for v in self.test_keys.values():
|
||||
PartTestTemplate.objects.create(
|
||||
test_name=v, part=p, description='A test template'
|
||||
)
|
||||
|
||||
self.assertEqual(PartTestTemplate.objects.count(), 3)
|
||||
|
||||
def test_key_field(self):
|
||||
"""Self that the key field is created and correctly filled."""
|
||||
PartTestTemplate = self.new_state.apps.get_model('part', 'parttesttemplate')
|
||||
|
||||
for key, value in self.test_keys.items():
|
||||
template = PartTestTemplate.objects.get(test_name=value)
|
||||
self.assertEqual(template.key, key)
|
||||
|
@ -378,8 +378,8 @@ class TestTemplateTest(TestCase):
|
||||
# Test the lowest-level part which has more associated tests
|
||||
variant = Part.objects.get(pk=10004)
|
||||
|
||||
self.assertEqual(variant.getTestTemplates().count(), 7)
|
||||
self.assertEqual(variant.getTestTemplates(include_parent=False).count(), 1)
|
||||
self.assertEqual(variant.getTestTemplates().count(), 6)
|
||||
self.assertEqual(variant.getTestTemplates(include_parent=False).count(), 0)
|
||||
self.assertEqual(variant.getTestTemplates(required=True).count(), 5)
|
||||
|
||||
def test_uniqueness(self):
|
||||
@ -389,21 +389,29 @@ class TestTemplateTest(TestCase):
|
||||
with self.assertRaises(ValidationError):
|
||||
PartTestTemplate.objects.create(part=variant, test_name='Record weight')
|
||||
|
||||
# Test that error is raised if we try to create a duplicate test name
|
||||
with self.assertRaises(ValidationError):
|
||||
PartTestTemplate.objects.create(
|
||||
part=variant, test_name='Check that chair is especially green'
|
||||
part=variant, test_name='Check chair is green'
|
||||
)
|
||||
|
||||
# Also should fail if we attempt to create a test that would generate the same key
|
||||
with self.assertRaises(ValidationError):
|
||||
PartTestTemplate.objects.create(
|
||||
template = PartTestTemplate.objects.create(
|
||||
part=variant, test_name='ReCoRD weiGHT '
|
||||
)
|
||||
|
||||
template.clean()
|
||||
|
||||
# But we should be able to create a new one!
|
||||
n = variant.getTestTemplates().count()
|
||||
|
||||
PartTestTemplate.objects.create(part=variant, test_name='A Sample Test')
|
||||
template = PartTestTemplate.objects.create(
|
||||
part=variant, test_name='A Sample Test'
|
||||
)
|
||||
|
||||
# Test key should have been saved
|
||||
self.assertEqual(template.key, 'asampletest')
|
||||
|
||||
self.assertEqual(variant.getTestTemplates().count(), n + 1)
|
||||
|
||||
|
@ -194,6 +194,7 @@ class ReportTest(InvenTreeAPITestCase):
|
||||
'part',
|
||||
'company',
|
||||
'location',
|
||||
'test_templates',
|
||||
'supplier_part',
|
||||
'stock',
|
||||
'stock_tests',
|
||||
|
@ -317,6 +317,6 @@ class StockTrackingAdmin(ImportExportModelAdmin):
|
||||
class StockItemTestResultAdmin(admin.ModelAdmin):
|
||||
"""Admin class for StockItemTestResult."""
|
||||
|
||||
list_display = ('stock_item', 'test', 'result', 'value')
|
||||
list_display = ('stock_item', 'test_name', 'result', 'value')
|
||||
|
||||
autocomplete_fields = ['stock_item']
|
||||
|
@ -38,6 +38,7 @@ from InvenTree.filters import (
|
||||
from InvenTree.helpers import (
|
||||
DownloadFile,
|
||||
extract_serial_numbers,
|
||||
generateTestKey,
|
||||
is_ajax,
|
||||
isNull,
|
||||
str2bool,
|
||||
@ -1188,22 +1189,87 @@ class StockAttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI):
|
||||
serializer_class = StockSerializers.StockItemAttachmentSerializer
|
||||
|
||||
|
||||
class StockItemTestResultDetail(RetrieveUpdateDestroyAPI):
|
||||
class StockItemTestResultMixin:
|
||||
"""Mixin class for the StockItemTestResult API endpoints."""
|
||||
|
||||
queryset = StockItemTestResult.objects.all()
|
||||
serializer_class = StockSerializers.StockItemTestResultSerializer
|
||||
|
||||
def get_serializer_context(self):
|
||||
"""Extend serializer context."""
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['request'] = self.request
|
||||
return ctx
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Set context before returning serializer."""
|
||||
try:
|
||||
kwargs['user_detail'] = str2bool(
|
||||
self.request.query_params.get('user_detail', False)
|
||||
)
|
||||
kwargs['template_detail'] = str2bool(
|
||||
self.request.query_params.get('template_detail', False)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
kwargs['context'] = self.get_serializer_context()
|
||||
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
|
||||
|
||||
class StockItemTestResultDetail(StockItemTestResultMixin, RetrieveUpdateDestroyAPI):
|
||||
"""Detail endpoint for StockItemTestResult."""
|
||||
|
||||
queryset = StockItemTestResult.objects.all()
|
||||
serializer_class = StockSerializers.StockItemTestResultSerializer
|
||||
pass
|
||||
|
||||
|
||||
class StockItemTestResultList(ListCreateDestroyAPIView):
|
||||
class StockItemTestResultFilter(rest_filters.FilterSet):
|
||||
"""API filter for the StockItemTestResult list."""
|
||||
|
||||
class Meta:
|
||||
"""Metaclass options."""
|
||||
|
||||
model = StockItemTestResult
|
||||
|
||||
# Simple filter fields
|
||||
fields = ['user', 'template', 'result', 'value']
|
||||
|
||||
build = rest_filters.ModelChoiceFilter(
|
||||
label='Build', queryset=Build.objects.all(), field_name='stock_item__build'
|
||||
)
|
||||
|
||||
part = rest_filters.ModelChoiceFilter(
|
||||
label='Part', queryset=Part.objects.all(), field_name='stock_item__part'
|
||||
)
|
||||
|
||||
required = rest_filters.BooleanFilter(
|
||||
label='Required', field_name='template__required'
|
||||
)
|
||||
|
||||
test = rest_filters.CharFilter(
|
||||
label='Test name (case insensitive)', method='filter_test_name'
|
||||
)
|
||||
|
||||
def filter_test_name(self, queryset, name, value):
|
||||
"""Filter by test name.
|
||||
|
||||
This method is provided for legacy support,
|
||||
where the StockItemTestResult model had a "test" field.
|
||||
Now the "test" name is stored against the PartTestTemplate model
|
||||
"""
|
||||
key = generateTestKey(value)
|
||||
return queryset.filter(template__key=key)
|
||||
|
||||
|
||||
class StockItemTestResultList(StockItemTestResultMixin, ListCreateDestroyAPIView):
|
||||
"""API endpoint for listing (and creating) a StockItemTestResult object."""
|
||||
|
||||
queryset = StockItemTestResult.objects.all()
|
||||
serializer_class = StockSerializers.StockItemTestResultSerializer
|
||||
|
||||
filterset_class = StockItemTestResultFilter
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
filterset_fields = ['test', 'user', 'result', 'value']
|
||||
filterset_fields = ['user', 'template', 'result', 'value']
|
||||
ordering_fields = ['date', 'result']
|
||||
|
||||
ordering = 'date'
|
||||
|
||||
@ -1213,18 +1279,6 @@ class StockItemTestResultList(ListCreateDestroyAPIView):
|
||||
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
# Filter by 'build'
|
||||
build = params.get('build', None)
|
||||
|
||||
if build is not None:
|
||||
try:
|
||||
build = Build.objects.get(pk=build)
|
||||
|
||||
queryset = queryset.filter(stock_item__build=build)
|
||||
|
||||
except (ValueError, Build.DoesNotExist):
|
||||
pass
|
||||
|
||||
# Filter by stock item
|
||||
item = params.get('stock_item', None)
|
||||
|
||||
@ -1251,19 +1305,6 @@ class StockItemTestResultList(ListCreateDestroyAPIView):
|
||||
|
||||
return queryset
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Set context before returning serializer."""
|
||||
try:
|
||||
kwargs['user_detail'] = str2bool(
|
||||
self.request.query_params.get('user_detail', False)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
kwargs['context'] = self.get_serializer_context()
|
||||
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Create a new test result object.
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
pk: 1
|
||||
fields:
|
||||
stock_item: 105
|
||||
test: "Firmware Version"
|
||||
template: 10
|
||||
value: "0xA1B2C3D4"
|
||||
result: True
|
||||
date: 2020-02-02
|
||||
@ -11,7 +11,7 @@
|
||||
pk: 2
|
||||
fields:
|
||||
stock_item: 105
|
||||
test: "Settings Checksum"
|
||||
template: 9
|
||||
value: "0xAABBCCDD"
|
||||
result: True
|
||||
date: 2020-02-02
|
||||
@ -20,7 +20,7 @@
|
||||
pk: 3
|
||||
fields:
|
||||
stock_item: 105
|
||||
test: "Temperature Test"
|
||||
template: 8
|
||||
result: False
|
||||
date: 2020-05-16
|
||||
notes: 'Got too hot or something'
|
||||
@ -29,7 +29,7 @@
|
||||
pk: 4
|
||||
fields:
|
||||
stock_item: 105
|
||||
test: "Temperature Test"
|
||||
template: 8
|
||||
result: True
|
||||
date: 2020-05-17
|
||||
notes: 'Passed temperature test by making it cooler'
|
||||
@ -38,7 +38,7 @@
|
||||
pk: 5
|
||||
fields:
|
||||
stock_item: 522
|
||||
test: 'applypaint'
|
||||
template: 2
|
||||
result: True
|
||||
date: 2020-05-17
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
pk: 6
|
||||
fields:
|
||||
stock_item: 522
|
||||
test: 'applypaint'
|
||||
template: 2
|
||||
result: False
|
||||
date: 2020-05-18
|
||||
|
||||
@ -54,7 +54,7 @@
|
||||
pk: 7
|
||||
fields:
|
||||
stock_item: 522
|
||||
test: 'Attach Legs'
|
||||
template: 4
|
||||
result: True
|
||||
date: 2020-05-17
|
||||
|
||||
@ -62,15 +62,6 @@
|
||||
pk: 8
|
||||
fields:
|
||||
stock_item: 522
|
||||
test: 'Check that chair is GreEn'
|
||||
template: 3
|
||||
result: True
|
||||
date: 2020-05-17
|
||||
|
||||
- model: stock.stockitemtestresult
|
||||
pk: 12345
|
||||
fields:
|
||||
stock_item: 522
|
||||
test: 'test strength of chair'
|
||||
result: False
|
||||
value: 100kg
|
||||
date: 2020-05-17
|
||||
date: 2024-02-15
|
||||
|
@ -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.save()
|
||||
|
||||
def add_test_result(self, create_template=True, **kwargs):
|
||||
"""Helper function to add a new StockItemTestResult.
|
||||
|
||||
The main purpose of this function is to allow lookup of the template,
|
||||
based on the provided test name.
|
||||
|
||||
If no template is found, a new one is created (if create_template=True).
|
||||
|
||||
Args:
|
||||
create_template: If True, create a new template if it does not exist
|
||||
|
||||
kwargs:
|
||||
template: The ID of the associated PartTestTemplate
|
||||
test_name: The name of the test (if the template is not provided)
|
||||
result: The result of the test
|
||||
value: The value of the test
|
||||
user: The user who performed the test
|
||||
notes: Any notes associated with the test
|
||||
"""
|
||||
template = kwargs.get('template', None)
|
||||
test_name = kwargs.pop('test_name', None)
|
||||
|
||||
test_key = InvenTree.helpers.generateTestKey(test_name)
|
||||
|
||||
if template is None and test_name is not None:
|
||||
# Attempt to find a matching template
|
||||
|
||||
template = PartModels.PartTestTemplate.objects.filter(
|
||||
part__tree_id=self.part.tree_id, key=test_key
|
||||
).first()
|
||||
|
||||
if template is None:
|
||||
if create_template:
|
||||
template = PartModels.PartTestTemplate.objects.create(
|
||||
part=self.part, test_name=test_name
|
||||
)
|
||||
else:
|
||||
raise ValidationError({
|
||||
'template': _('Test template does not exist')
|
||||
})
|
||||
|
||||
kwargs['template'] = template
|
||||
kwargs['stock_item'] = self
|
||||
|
||||
return StockItemTestResult.objects.create(**kwargs)
|
||||
|
||||
def can_merge(self, other=None, raise_error=False, **kwargs):
|
||||
"""Check if this stock item can be merged into another stock item."""
|
||||
allow_mismatched_suppliers = kwargs.get('allow_mismatched_suppliers', False)
|
||||
@ -1623,6 +1669,9 @@ class StockItem(
|
||||
if len(other_items) == 0:
|
||||
return
|
||||
|
||||
# Keep track of the tree IDs that are being merged
|
||||
tree_ids = {self.tree_id}
|
||||
|
||||
user = kwargs.get('user', None)
|
||||
location = kwargs.get('location', None)
|
||||
notes = kwargs.get('notes', None)
|
||||
@ -1634,6 +1683,8 @@ class StockItem(
|
||||
if not self.can_merge(other, raise_error=raise_error, **kwargs):
|
||||
return
|
||||
|
||||
tree_ids.add(other.tree_id)
|
||||
|
||||
for other in other_items:
|
||||
self.quantity += other.quantity
|
||||
|
||||
@ -1665,6 +1716,14 @@ class StockItem(
|
||||
self.location = location
|
||||
self.save()
|
||||
|
||||
# Rebuild stock trees as required
|
||||
try:
|
||||
for tree_id in tree_ids:
|
||||
StockItem.objects.partial_rebuild(tree_id=tree_id)
|
||||
except Exception:
|
||||
logger.warning('Rebuilding entire StockItem tree')
|
||||
StockItem.objects.rebuild()
|
||||
|
||||
@transaction.atomic
|
||||
def splitStock(self, quantity, location=None, user=None, **kwargs):
|
||||
"""Split this stock item into two items, in the same location.
|
||||
@ -1994,19 +2053,24 @@ class StockItem(
|
||||
|
||||
results.delete()
|
||||
|
||||
def getTestResults(self, test=None, result=None, user=None):
|
||||
def getTestResults(self, template=None, test=None, result=None, user=None):
|
||||
"""Return all test results associated with this StockItem.
|
||||
|
||||
Optionally can filter results by:
|
||||
- Test template ID
|
||||
- Test name
|
||||
- Test result
|
||||
- User
|
||||
"""
|
||||
results = self.test_results
|
||||
|
||||
if template:
|
||||
results = results.filter(template=template)
|
||||
|
||||
if test:
|
||||
# Filter by test name
|
||||
results = results.filter(test=test)
|
||||
test_key = InvenTree.helpers.generateTestKey(test)
|
||||
results = results.filter(template__key=test_key)
|
||||
|
||||
if result is not None:
|
||||
# Filter by test status
|
||||
@ -2037,8 +2101,7 @@ class StockItem(
|
||||
result_map = {}
|
||||
|
||||
for result in results:
|
||||
key = InvenTree.helpers.generateTestKey(result.test)
|
||||
result_map[key] = result
|
||||
result_map[result.key] = result
|
||||
|
||||
# Do we wish to "cascade" and include test results from installed stock items?
|
||||
cascade = kwargs.get('cascade', False)
|
||||
@ -2098,7 +2161,7 @@ class StockItem(
|
||||
|
||||
def hasRequiredTests(self):
|
||||
"""Return True if there are any 'required tests' associated with this StockItem."""
|
||||
return self.part.getRequiredTests().count() > 0
|
||||
return self.required_test_count > 0
|
||||
|
||||
def passedAllRequiredTests(self):
|
||||
"""Returns True if this StockItem has passed all required tests."""
|
||||
@ -2286,7 +2349,7 @@ class StockItemTestResult(InvenTree.models.InvenTreeMetadataModel):
|
||||
|
||||
Attributes:
|
||||
stock_item: Link to StockItem
|
||||
test: Test name (simple string matching)
|
||||
template: Link to TestTemplate
|
||||
result: Test result value (pass / fail / etc)
|
||||
value: Recorded test output value (optional)
|
||||
attachment: Link to StockItem attachment (optional)
|
||||
@ -2295,6 +2358,10 @@ class StockItemTestResult(InvenTree.models.InvenTreeMetadataModel):
|
||||
date: Date the test result was recorded
|
||||
"""
|
||||
|
||||
def __str__(self):
|
||||
"""Return string representation."""
|
||||
return f'{self.test_name} - {self.result}'
|
||||
|
||||
@staticmethod
|
||||
def get_api_url():
|
||||
"""Return API url."""
|
||||
@ -2334,14 +2401,22 @@ class StockItemTestResult(InvenTree.models.InvenTreeMetadataModel):
|
||||
@property
|
||||
def key(self):
|
||||
"""Return key for test."""
|
||||
return InvenTree.helpers.generateTestKey(self.test)
|
||||
return InvenTree.helpers.generateTestKey(self.test_name)
|
||||
|
||||
stock_item = models.ForeignKey(
|
||||
StockItem, on_delete=models.CASCADE, related_name='test_results'
|
||||
)
|
||||
|
||||
test = models.CharField(
|
||||
blank=False, max_length=100, verbose_name=_('Test'), help_text=_('Test name')
|
||||
@property
|
||||
def test_name(self):
|
||||
"""Return the test name of the associated test template."""
|
||||
return self.template.test_name
|
||||
|
||||
template = models.ForeignKey(
|
||||
'part.parttesttemplate',
|
||||
on_delete=models.CASCADE,
|
||||
blank=False,
|
||||
related_name='test_results',
|
||||
)
|
||||
|
||||
result = models.BooleanField(
|
||||
|
@ -1,5 +1,6 @@
|
||||
"""JSON serializers for Stock app."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
@ -23,7 +24,7 @@ import part.models as part_models
|
||||
import stock.filters
|
||||
from company.serializers import SupplierPartSerializer
|
||||
from InvenTree.serializers import InvenTreeCurrencySerializer, InvenTreeDecimalField
|
||||
from part.serializers import PartBriefSerializer
|
||||
from part.serializers import PartBriefSerializer, PartTestTemplateSerializer
|
||||
|
||||
from .models import (
|
||||
StockItem,
|
||||
@ -34,6 +35,8 @@ from .models import (
|
||||
StockLocationType,
|
||||
)
|
||||
|
||||
logger = logging.getLogger('inventree')
|
||||
|
||||
|
||||
class LocationBriefSerializer(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
"""Provides a brief serializer for a StockLocation object."""
|
||||
@ -56,8 +59,6 @@ class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializ
|
||||
fields = [
|
||||
'pk',
|
||||
'stock_item',
|
||||
'key',
|
||||
'test',
|
||||
'result',
|
||||
'value',
|
||||
'attachment',
|
||||
@ -65,6 +66,8 @@ class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializ
|
||||
'user',
|
||||
'user_detail',
|
||||
'date',
|
||||
'template',
|
||||
'template_detail',
|
||||
]
|
||||
|
||||
read_only_fields = ['pk', 'user', 'date']
|
||||
@ -72,20 +75,67 @@ class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializ
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Add detail fields."""
|
||||
user_detail = kwargs.pop('user_detail', False)
|
||||
template_detail = kwargs.pop('template_detail', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if user_detail is not True:
|
||||
self.fields.pop('user_detail')
|
||||
|
||||
if template_detail is not True:
|
||||
self.fields.pop('template_detail')
|
||||
|
||||
user_detail = InvenTree.serializers.UserSerializer(source='user', read_only=True)
|
||||
|
||||
key = serializers.CharField(read_only=True)
|
||||
template = serializers.PrimaryKeyRelatedField(
|
||||
queryset=part_models.PartTestTemplate.objects.all(),
|
||||
many=False,
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text=_('Template'),
|
||||
label=_('Test template for this result'),
|
||||
)
|
||||
|
||||
template_detail = PartTestTemplateSerializer(source='template', read_only=True)
|
||||
|
||||
attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField(
|
||||
required=False
|
||||
)
|
||||
|
||||
def validate(self, data):
|
||||
"""Validate the test result data."""
|
||||
stock_item = data['stock_item']
|
||||
template = data.get('template', None)
|
||||
|
||||
# To support legacy API, we can accept a test name instead of a template
|
||||
# In such a case, we use the test name to lookup the appropriate template
|
||||
test_name = self.context['request'].data.get('test', None)
|
||||
|
||||
if not template and not test_name:
|
||||
raise ValidationError(_('Template ID or test name must be provided'))
|
||||
|
||||
if not template:
|
||||
test_key = InvenTree.helpers.generateTestKey(test_name)
|
||||
|
||||
# Find a template based on name
|
||||
if template := part_models.PartTestTemplate.objects.filter(
|
||||
part__tree_id=stock_item.part.tree_id, key=test_key
|
||||
).first():
|
||||
data['template'] = template
|
||||
|
||||
else:
|
||||
logger.info(
|
||||
"No matching test template found for '%s' - creating a new template",
|
||||
test_name,
|
||||
)
|
||||
|
||||
# Create a new test template based on the provided dasta
|
||||
data['template'] = part_models.PartTestTemplate.objects.create(
|
||||
part=stock_item.part, test_name=test_name
|
||||
)
|
||||
|
||||
return super().validate(data)
|
||||
|
||||
|
||||
class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer):
|
||||
"""Brief serializers for a StockItem."""
|
||||
|
@ -292,6 +292,7 @@
|
||||
constructForm('{% url "api-stock-test-result-list" %}', {
|
||||
method: 'POST',
|
||||
fields: stockItemTestResultFields({
|
||||
part: {{ item.part.pk }},
|
||||
stock_item: {{ item.pk }},
|
||||
}),
|
||||
title: '{% trans "Add Test Result" escape %}',
|
||||
|
@ -19,7 +19,7 @@ import part.models
|
||||
from common.models import InvenTreeSetting
|
||||
from InvenTree.status_codes import StockHistoryCode, StockStatus
|
||||
from InvenTree.unit_test import InvenTreeAPITestCase
|
||||
from part.models import Part
|
||||
from part.models import Part, PartTestTemplate
|
||||
from stock.models import (
|
||||
StockItem,
|
||||
StockItemTestResult,
|
||||
@ -34,6 +34,7 @@ class StockAPITestCase(InvenTreeAPITestCase):
|
||||
fixtures = [
|
||||
'category',
|
||||
'part',
|
||||
'test_templates',
|
||||
'bom',
|
||||
'company',
|
||||
'location',
|
||||
@ -1559,6 +1560,8 @@ class StockTestResultTest(StockAPITestCase):
|
||||
response = self.client.get(url)
|
||||
n = len(response.data)
|
||||
|
||||
# Test upload using test name (legacy method)
|
||||
# Note that a new test template will be created
|
||||
data = {
|
||||
'stock_item': 105,
|
||||
'test': 'Checked Steam Valve',
|
||||
@ -1569,6 +1572,9 @@ class StockTestResultTest(StockAPITestCase):
|
||||
|
||||
response = self.post(url, data, expected_code=201)
|
||||
|
||||
# Check that a new test template has been created
|
||||
test_template = PartTestTemplate.objects.get(key='checkedsteamvalve')
|
||||
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(len(response.data), n + 1)
|
||||
|
||||
@ -1581,6 +1587,27 @@ class StockTestResultTest(StockAPITestCase):
|
||||
self.assertEqual(test['value'], '150kPa')
|
||||
self.assertEqual(test['user'], self.user.pk)
|
||||
|
||||
# Test upload using template reference (new method)
|
||||
data = {
|
||||
'stock_item': 105,
|
||||
'template': test_template.pk,
|
||||
'result': True,
|
||||
'value': '75kPa',
|
||||
}
|
||||
|
||||
response = self.post(url, data, expected_code=201)
|
||||
|
||||
# Check that a new test template has been created
|
||||
self.assertEqual(test_template.test_results.all().count(), 2)
|
||||
|
||||
# List test results against the template
|
||||
response = self.client.get(url, data={'template': test_template.pk})
|
||||
|
||||
self.assertEqual(len(response.data), 2)
|
||||
|
||||
for item in response.data:
|
||||
self.assertEqual(item['template'], test_template.pk)
|
||||
|
||||
def test_post_bitmap(self):
|
||||
"""2021-08-25.
|
||||
|
||||
@ -1598,14 +1625,15 @@ class StockTestResultTest(StockAPITestCase):
|
||||
with open(image_file, 'rb') as bitmap:
|
||||
data = {
|
||||
'stock_item': 105,
|
||||
'test': 'Checked Steam Valve',
|
||||
'test': 'Temperature Test',
|
||||
'result': False,
|
||||
'value': '150kPa',
|
||||
'notes': 'I guess there was just too much pressure?',
|
||||
'value': '550C',
|
||||
'notes': 'I guess there was just too much heat?',
|
||||
'attachment': bitmap,
|
||||
}
|
||||
|
||||
response = self.client.post(self.get_url(), data)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
||||
# Check that an attachment has been uploaded
|
||||
@ -1619,23 +1647,34 @@ class StockTestResultTest(StockAPITestCase):
|
||||
|
||||
url = reverse('api-stock-test-result-list')
|
||||
|
||||
stock_item = StockItem.objects.get(pk=1)
|
||||
|
||||
# Ensure the part is marked as "trackable"
|
||||
p = stock_item.part
|
||||
p.trackable = True
|
||||
p.save()
|
||||
|
||||
# Create some objects (via the API)
|
||||
for _ii in range(50):
|
||||
response = self.post(
|
||||
url,
|
||||
{
|
||||
'stock_item': 1,
|
||||
'stock_item': stock_item.pk,
|
||||
'test': f'Some test {_ii}',
|
||||
'result': True,
|
||||
'value': 'Test result value',
|
||||
},
|
||||
expected_code=201,
|
||||
# expected_code=201,
|
||||
)
|
||||
|
||||
tests.append(response.data['pk'])
|
||||
|
||||
self.assertEqual(StockItemTestResult.objects.count(), n + 50)
|
||||
|
||||
# Filter test results by part
|
||||
response = self.get(url, {'part': p.pk}, expected_code=200)
|
||||
self.assertEqual(len(response.data), 50)
|
||||
|
||||
# Attempt a delete without providing items
|
||||
self.delete(url, {}, expected_code=400)
|
||||
|
||||
@ -1838,6 +1877,7 @@ class StockMetadataAPITest(InvenTreeAPITestCase):
|
||||
fixtures = [
|
||||
'category',
|
||||
'part',
|
||||
'test_templates',
|
||||
'bom',
|
||||
'company',
|
||||
'location',
|
||||
|
@ -133,3 +133,100 @@ class TestScheduledForDeletionMigration(MigratorTestCase):
|
||||
|
||||
# All the "scheduled for deletion" items have been removed
|
||||
self.assertEqual(StockItem.objects.count(), 3)
|
||||
|
||||
|
||||
class TestTestResultMigration(MigratorTestCase):
|
||||
"""Unit tests for StockItemTestResult data migrations."""
|
||||
|
||||
migrate_from = ('stock', '0103_stock_location_types')
|
||||
migrate_to = ('stock', '0107_remove_stockitemtestresult_test_and_more')
|
||||
|
||||
test_keys = {
|
||||
'appliedpaint': 'Applied Paint',
|
||||
'programmed': 'Programmed',
|
||||
'checkedresultcode': 'Checked Result CODE',
|
||||
}
|
||||
|
||||
def prepare(self):
|
||||
"""Create initial data."""
|
||||
Part = self.old_state.apps.get_model('part', 'part')
|
||||
PartTestTemplate = self.old_state.apps.get_model('part', 'parttesttemplate')
|
||||
StockItem = self.old_state.apps.get_model('stock', 'stockitem')
|
||||
StockItemTestResult = self.old_state.apps.get_model(
|
||||
'stock', 'stockitemtestresult'
|
||||
)
|
||||
|
||||
# Create a test part
|
||||
parent_part = Part.objects.create(
|
||||
name='Parent Part',
|
||||
description='A parent part',
|
||||
is_template=True,
|
||||
active=True,
|
||||
trackable=True,
|
||||
level=0,
|
||||
tree_id=1,
|
||||
lft=0,
|
||||
rght=0,
|
||||
)
|
||||
|
||||
# Create some child parts
|
||||
children = [
|
||||
Part.objects.create(
|
||||
name=f'Child part {idx}',
|
||||
description='A child part',
|
||||
variant_of=parent_part,
|
||||
active=True,
|
||||
trackable=True,
|
||||
level=0,
|
||||
tree_id=1,
|
||||
lft=0,
|
||||
rght=0,
|
||||
)
|
||||
for idx in range(3)
|
||||
]
|
||||
|
||||
# Create some stock items
|
||||
for ii, child in enumerate(children):
|
||||
for jj in range(4):
|
||||
si = StockItem.objects.create(
|
||||
part=child,
|
||||
serial=str(1 + ii * jj),
|
||||
quantity=1,
|
||||
tree_id=0,
|
||||
level=0,
|
||||
lft=0,
|
||||
rght=0,
|
||||
)
|
||||
|
||||
# Create some test results
|
||||
for _k, v in self.test_keys.items():
|
||||
StockItemTestResult.objects.create(
|
||||
stock_item=si, test=v, result=True, value=f'Result: {ii} : {jj}'
|
||||
)
|
||||
|
||||
# Check initial record counts
|
||||
self.assertEqual(PartTestTemplate.objects.count(), 0)
|
||||
self.assertEqual(StockItemTestResult.objects.count(), 36)
|
||||
|
||||
def test_migration(self):
|
||||
"""Test that the migrations were applied as expected."""
|
||||
Part = self.new_state.apps.get_model('part', 'part')
|
||||
PartTestTemplate = self.new_state.apps.get_model('part', 'parttesttemplate')
|
||||
StockItem = self.new_state.apps.get_model('stock', 'stockitem')
|
||||
StockItemTestResult = self.new_state.apps.get_model(
|
||||
'stock', 'stockitemtestresult'
|
||||
)
|
||||
|
||||
# Test that original record counts are correct
|
||||
self.assertEqual(Part.objects.count(), 4)
|
||||
self.assertEqual(StockItem.objects.count(), 12)
|
||||
self.assertEqual(StockItemTestResult.objects.count(), 36)
|
||||
|
||||
# Two more test templates should have been created
|
||||
self.assertEqual(PartTestTemplate.objects.count(), 3)
|
||||
|
||||
for k in self.test_keys.keys():
|
||||
self.assertTrue(PartTestTemplate.objects.filter(key=k).exists())
|
||||
|
||||
for result in StockItemTestResult.objects.all():
|
||||
self.assertIsNotNone(result.template)
|
||||
|
@ -12,7 +12,7 @@ from company.models import Company
|
||||
from InvenTree.status_codes import StockHistoryCode
|
||||
from InvenTree.unit_test import InvenTreeTestCase
|
||||
from order.models import SalesOrder
|
||||
from part.models import Part
|
||||
from part.models import Part, PartTestTemplate
|
||||
|
||||
from .models import StockItem, StockItemTestResult, StockItemTracking, StockLocation
|
||||
|
||||
@ -1086,31 +1086,51 @@ class TestResultTest(StockTestBase):
|
||||
|
||||
self.assertEqual(status['total'], 5)
|
||||
self.assertEqual(status['passed'], 2)
|
||||
self.assertEqual(status['failed'], 2)
|
||||
self.assertEqual(status['failed'], 1)
|
||||
|
||||
self.assertFalse(item.passedAllRequiredTests())
|
||||
|
||||
# Add some new test results to make it pass!
|
||||
test = StockItemTestResult.objects.get(pk=12345)
|
||||
test.result = True
|
||||
test = StockItemTestResult.objects.get(pk=8)
|
||||
test.result = False
|
||||
test.save()
|
||||
|
||||
status = item.requiredTestStatus()
|
||||
self.assertEqual(status['total'], 5)
|
||||
self.assertEqual(status['passed'], 1)
|
||||
self.assertEqual(status['failed'], 2)
|
||||
|
||||
template = PartTestTemplate.objects.get(pk=3)
|
||||
|
||||
StockItemTestResult.objects.create(
|
||||
stock_item=item, test='sew cushion', result=True
|
||||
stock_item=item, template=template, result=True
|
||||
)
|
||||
|
||||
# Still should be failing at this point,
|
||||
# as the most recent "apply paint" test was False
|
||||
self.assertFalse(item.passedAllRequiredTests())
|
||||
|
||||
template = PartTestTemplate.objects.get(pk=2)
|
||||
|
||||
# Add a new test result against this required test
|
||||
StockItemTestResult.objects.create(
|
||||
stock_item=item,
|
||||
test='apply paint',
|
||||
template=template,
|
||||
date=datetime.datetime(2022, 12, 12),
|
||||
result=True,
|
||||
)
|
||||
|
||||
self.assertFalse(item.passedAllRequiredTests())
|
||||
|
||||
# Generate a passing result for all required tests
|
||||
for template in item.part.getRequiredTests():
|
||||
StockItemTestResult.objects.create(
|
||||
stock_item=item,
|
||||
template=template,
|
||||
result=True,
|
||||
date=datetime.datetime(2025, 12, 12),
|
||||
)
|
||||
|
||||
self.assertTrue(item.passedAllRequiredTests())
|
||||
|
||||
def test_duplicate_item_tests(self):
|
||||
@ -1140,17 +1160,9 @@ class TestResultTest(StockTestBase):
|
||||
item.save()
|
||||
|
||||
# Do some tests!
|
||||
StockItemTestResult.objects.create(
|
||||
stock_item=item, test='Firmware', result=True
|
||||
)
|
||||
|
||||
StockItemTestResult.objects.create(
|
||||
stock_item=item, test='Paint Color', result=True, value='Red'
|
||||
)
|
||||
|
||||
StockItemTestResult.objects.create(
|
||||
stock_item=item, test='Applied Sticker', result=False
|
||||
)
|
||||
item.add_test_result(test_name='Firmware', result=True)
|
||||
item.add_test_result(test_name='Paint Color', result=True, value='Red')
|
||||
item.add_test_result(test_name='Applied Sticker', result=False)
|
||||
|
||||
self.assertEqual(item.test_results.count(), 3)
|
||||
self.assertEqual(item.quantity, 50)
|
||||
@ -1163,7 +1175,7 @@ class TestResultTest(StockTestBase):
|
||||
self.assertEqual(item.test_results.count(), 3)
|
||||
self.assertEqual(item2.test_results.count(), 3)
|
||||
|
||||
StockItemTestResult.objects.create(stock_item=item2, test='A new test')
|
||||
item2.add_test_result(test_name='A new test')
|
||||
|
||||
self.assertEqual(item.test_results.count(), 3)
|
||||
self.assertEqual(item2.test_results.count(), 4)
|
||||
@ -1172,7 +1184,7 @@ class TestResultTest(StockTestBase):
|
||||
item2.serializeStock(1, [100], self.user)
|
||||
|
||||
# Add a test result to the parent *after* serialization
|
||||
StockItemTestResult.objects.create(stock_item=item2, test='abcde')
|
||||
item2.add_test_result(test_name='abcde')
|
||||
|
||||
self.assertEqual(item2.test_results.count(), 5)
|
||||
|
||||
@ -1201,11 +1213,20 @@ class TestResultTest(StockTestBase):
|
||||
)
|
||||
|
||||
# Now, create some test results against the sub item
|
||||
# Ensure there is a matching PartTestTemplate
|
||||
if template := PartTestTemplate.objects.filter(
|
||||
part=item.part, key='firmwareversion'
|
||||
).first():
|
||||
pass
|
||||
else:
|
||||
template = PartTestTemplate.objects.create(
|
||||
part=item.part, test_name='Firmware Version', required=True
|
||||
)
|
||||
|
||||
# First test is overshadowed by the same test for the parent part
|
||||
StockItemTestResult.objects.create(
|
||||
stock_item=sub_item,
|
||||
test='firmware version',
|
||||
template=template,
|
||||
date=datetime.datetime.now().date(),
|
||||
result=True,
|
||||
)
|
||||
@ -1214,10 +1235,19 @@ class TestResultTest(StockTestBase):
|
||||
tests = item.testResultMap(include_installed=True)
|
||||
self.assertEqual(len(tests), 3)
|
||||
|
||||
if template := PartTestTemplate.objects.filter(
|
||||
part=item.part, key='somenewtest'
|
||||
).first():
|
||||
pass
|
||||
else:
|
||||
template = PartTestTemplate.objects.create(
|
||||
part=item.part, test_name='Some New Test', required=True
|
||||
)
|
||||
|
||||
# Now, add a *unique* test result for the sub item
|
||||
StockItemTestResult.objects.create(
|
||||
stock_item=sub_item,
|
||||
test='some new test',
|
||||
template=template,
|
||||
date=datetime.datetime.now().date(),
|
||||
result=False,
|
||||
value='abcde',
|
||||
|
@ -68,6 +68,8 @@ function getModelRenderer(model) {
|
||||
return renderPartCategory;
|
||||
case 'partparametertemplate':
|
||||
return renderPartParameterTemplate;
|
||||
case 'parttesttemplate':
|
||||
return renderPartTestTemplate;
|
||||
case 'purchaseorder':
|
||||
return renderPurchaseOrder;
|
||||
case 'salesorder':
|
||||
@ -483,6 +485,18 @@ function renderPartParameterTemplate(data, parameters={}) {
|
||||
}
|
||||
|
||||
|
||||
function renderPartTestTemplate(data, parameters={}) {
|
||||
|
||||
return renderModel(
|
||||
{
|
||||
text: data.test_name,
|
||||
textSecondary: data.description,
|
||||
},
|
||||
parameters
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Renderer for "ManufacturerPart" model
|
||||
function renderManufacturerPart(data, parameters={}) {
|
||||
|
||||
|
@ -2867,6 +2867,18 @@ function loadPartTestTemplateTable(table, options) {
|
||||
field: 'test_name',
|
||||
title: '{% trans "Test Name" %}',
|
||||
sortable: true,
|
||||
formatter: function(value, row) {
|
||||
let html = value;
|
||||
|
||||
if (row.results && row.results > 0) {
|
||||
html += `
|
||||
<span class='badge bg-dark rounded-pill float-right' title='${row.results} {% trans "results" %}'>
|
||||
${row.results}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
@ -2909,7 +2921,7 @@ function loadPartTestTemplateTable(table, options) {
|
||||
} else {
|
||||
var text = '{% trans "This test is defined for a parent part" %}';
|
||||
|
||||
return renderLink(text, `/part/${row.part}/tests/`);
|
||||
return renderLink(text, `/part/${row.part}/?display=test-templates`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1381,7 +1381,11 @@ function formatDate(row) {
|
||||
/* Construct set of default fields for a StockItemTestResult */
|
||||
function stockItemTestResultFields(options={}) {
|
||||
let fields = {
|
||||
test: {},
|
||||
template: {
|
||||
filters: {
|
||||
include_inherited: true,
|
||||
}
|
||||
},
|
||||
result: {},
|
||||
value: {},
|
||||
attachment: {},
|
||||
@ -1393,6 +1397,10 @@ function stockItemTestResultFields(options={}) {
|
||||
},
|
||||
};
|
||||
|
||||
if (options.part) {
|
||||
fields.template.filters.part = options.part;
|
||||
}
|
||||
|
||||
if (options.stock_item) {
|
||||
fields.stock_item.value = options.stock_item;
|
||||
}
|
||||
@ -1412,6 +1420,7 @@ function loadStockTestResultsTable(table, options) {
|
||||
|
||||
let params = {
|
||||
part: options.part,
|
||||
include_inherited: true,
|
||||
};
|
||||
|
||||
var filters = loadTableFilters(filterKey, params);
|
||||
@ -1424,17 +1433,16 @@ function loadStockTestResultsTable(table, options) {
|
||||
|
||||
let html = '';
|
||||
|
||||
if (row.requires_attachment == false && row.requires_value == false && !row.result) {
|
||||
if (row.parent != parent_node && row.requires_attachment == false && row.requires_value == false && !row.result) {
|
||||
// Enable a "quick tick" option for this test result
|
||||
html += makeIconButton('fa-check-circle icon-green', 'button-test-tick', row.test_name, '{% trans "Pass test" %}');
|
||||
}
|
||||
|
||||
html += makeIconButton('fa-plus icon-green', 'button-test-add', row.test_name, '{% trans "Add test result" %}');
|
||||
html += makeIconButton('fa-plus icon-green', 'button-test-add', row.templateId, '{% trans "Add test result" %}');
|
||||
|
||||
if (!grouped && row.result != null) {
|
||||
var pk = row.pk;
|
||||
html += makeEditButton('button-test-edit', pk, '{% trans "Edit test result" %}');
|
||||
html += makeDeleteButton('button-test-delete', pk, '{% trans "Delete test result" %}');
|
||||
html += makeEditButton('button-test-edit', row.testId, '{% trans "Edit test result" %}');
|
||||
html += makeDeleteButton('button-test-delete', row.testId, '{% trans "Delete test result" %}');
|
||||
}
|
||||
|
||||
return wrapButtons(html);
|
||||
@ -1532,9 +1540,14 @@ function loadStockTestResultsTable(table, options) {
|
||||
],
|
||||
onLoadSuccess: function(tableData) {
|
||||
|
||||
// Set "parent" for each existing row
|
||||
tableData.forEach(function(item, idx) {
|
||||
tableData[idx].parent = parent_node;
|
||||
// Construct an initial dataset based on the returned templates
|
||||
let results = tableData.map((template) => {
|
||||
return {
|
||||
...template,
|
||||
templateId: template.pk,
|
||||
parent: parent_node,
|
||||
results: []
|
||||
};
|
||||
});
|
||||
|
||||
// Once the test template data are loaded, query for test results
|
||||
@ -1545,6 +1558,7 @@ function loadStockTestResultsTable(table, options) {
|
||||
stock_item: options.stock_item,
|
||||
user_detail: true,
|
||||
attachment_detail: true,
|
||||
template_detail: false,
|
||||
ordering: '-date',
|
||||
};
|
||||
|
||||
@ -1561,54 +1575,40 @@ function loadStockTestResultsTable(table, options) {
|
||||
query_params,
|
||||
{
|
||||
success: function(data) {
|
||||
// Iterate through the returned test data
|
||||
data.forEach(function(item) {
|
||||
|
||||
var match = false;
|
||||
var override = false;
|
||||
data.sort((a, b) => {
|
||||
return a.pk < b.pk;
|
||||
}).forEach((row) => {
|
||||
let idx = results.findIndex((template) => {
|
||||
return template.templateId == row.template;
|
||||
});
|
||||
|
||||
// Extract the simplified test key
|
||||
var key = item.key;
|
||||
if (idx > -1) {
|
||||
|
||||
// Attempt to associate this result with an existing test
|
||||
for (var idx = 0; idx < tableData.length; idx++) {
|
||||
results[idx].results.push(row);
|
||||
|
||||
var row = tableData[idx];
|
||||
|
||||
if (key == row.key) {
|
||||
|
||||
item.test_name = row.test_name;
|
||||
item.test_description = row.description;
|
||||
item.required = row.required;
|
||||
|
||||
if (row.result == null) {
|
||||
item.parent = parent_node;
|
||||
tableData[idx] = item;
|
||||
override = true;
|
||||
} else {
|
||||
item.parent = row.pk;
|
||||
}
|
||||
|
||||
match = true;
|
||||
|
||||
break;
|
||||
// Check if a test result is already recorded
|
||||
if (results[idx].testId) {
|
||||
// Push this result into the results array
|
||||
results.push({
|
||||
...results[idx],
|
||||
...row,
|
||||
parent: results[idx].templateId,
|
||||
testId: row.pk,
|
||||
});
|
||||
} else {
|
||||
// First result - update the parent row
|
||||
results[idx] = {
|
||||
...row,
|
||||
...results[idx],
|
||||
testId: row.pk,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// No match could be found
|
||||
if (!match) {
|
||||
item.test_name = item.test;
|
||||
item.parent = parent_node;
|
||||
}
|
||||
|
||||
if (!override) {
|
||||
tableData.push(item);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
// Push data back into the table
|
||||
table.bootstrapTable('load', tableData);
|
||||
table.bootstrapTable('load', results);
|
||||
}
|
||||
}
|
||||
);
|
||||
@ -1645,25 +1645,17 @@ function loadStockTestResultsTable(table, options) {
|
||||
$(table).on('click', '.button-test-add', function() {
|
||||
var button = $(this);
|
||||
|
||||
var test_name = button.attr('pk');
|
||||
var templateId = button.attr('pk');
|
||||
|
||||
let fields = stockItemTestResultFields();
|
||||
|
||||
fields['stock_item']['value'] = options.stock_item;
|
||||
fields['template']['value'] = templateId;
|
||||
fields['template']['filters']['part'] = options.part;
|
||||
|
||||
constructForm('{% url "api-stock-test-result-list" %}', {
|
||||
method: 'POST',
|
||||
fields: {
|
||||
test: {
|
||||
value: test_name,
|
||||
},
|
||||
result: {},
|
||||
value: {},
|
||||
attachment: {},
|
||||
notes: {
|
||||
icon: 'fa-sticky-note',
|
||||
},
|
||||
stock_item: {
|
||||
value: options.stock_item,
|
||||
hidden: true,
|
||||
}
|
||||
},
|
||||
fields: fields,
|
||||
title: '{% trans "Add Test Result" %}',
|
||||
onSuccess: reloadTestTable,
|
||||
});
|
||||
@ -1692,11 +1684,9 @@ function loadStockTestResultsTable(table, options) {
|
||||
|
||||
var url = `/api/stock/test/${pk}/`;
|
||||
|
||||
var row = $(table).bootstrapTable('getRowByUniqueId', pk);
|
||||
|
||||
var html = `
|
||||
<div class='alert alert-block alert-danger'>
|
||||
<strong>{% trans "Delete test result" %}:</strong> ${row.test_name || row.test || row.key}
|
||||
<strong>{% trans "Delete test result" %}</strong>
|
||||
</div>`;
|
||||
|
||||
constructForm(url, {
|
||||
|
Reference in New Issue
Block a user