Skip to content

Commit

Permalink
[WIP] Test result table (#6430)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
SchrodingersGat authored Feb 18, 2024
1 parent ad1c1ae commit 0f51127
Show file tree
Hide file tree
Showing 50 changed files with 1,506 additions and 244 deletions.
9 changes: 7 additions & 2 deletions InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
11 changes: 9 additions & 2 deletions InvenTree/InvenTree/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 19 additions & 0 deletions InvenTree/InvenTree/management/commands/check_migrations.py
Original file line number Diff line number Diff line change
@@ -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')
14 changes: 14 additions & 0 deletions InvenTree/InvenTree/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,20 @@ def test_model_mixin(self):
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."""
Expand Down
3 changes: 2 additions & 1 deletion InvenTree/build/test_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -655,9 +655,10 @@ def test_complete_with_required_tests(self):
# 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
Expand Down
1 change: 1 addition & 0 deletions InvenTree/part/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',)

Expand Down
40 changes: 32 additions & 8 deletions InvenTree/part/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,29 +389,53 @@ def filter_part(self, queryset, name, part):
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):
"""Detail endpoint for PartTestTemplate model."""

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 PartTestTemplateList(ListCreateAPI):
class PartTestTemplateDetail(PartTestTemplateMixin, RetrieveUpdateDestroyAPI):
"""Detail endpoint for PartTestTemplate model."""

pass


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'

Expand Down
31 changes: 27 additions & 4 deletions InvenTree/part/fixtures/test_templates.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,43 +4,66 @@
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
- model: part.parttesttemplate
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 changes: 18 additions & 0 deletions InvenTree/part/migrations/0120_parttesttemplate_key.py
Original file line number Diff line number Diff line change
@@ -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 changes: 29 additions & 0 deletions InvenTree/part/migrations/0121_auto_20240207_0344.py
Original file line number Diff line number Diff line change
@@ -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)
]
39 changes: 20 additions & 19 deletions InvenTree/part/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -3408,6 +3412,8 @@ def clean(self):
"""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()

Expand All @@ -3418,30 +3424,18 @@ def validate_unique(self, exclude=None):
'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,
Expand All @@ -3457,6 +3451,13 @@ def key(self):
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,
Expand Down
11 changes: 11 additions & 0 deletions InvenTree/part/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,20 @@ class Meta:
'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):
Expand Down
4 changes: 2 additions & 2 deletions InvenTree/part/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -806,14 +806,14 @@ def test_test_templates(self):
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(
Expand Down
Loading

0 comments on commit 0f51127

Please sign in to comment.