Skip to content
11 changes: 9 additions & 2 deletions src/backend/InvenTree/part/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,17 @@ class PartRelatedAdmin(admin.ModelAdmin):
class PartTestTemplateAdmin(admin.ModelAdmin):
"""Admin class for the PartTestTemplate model."""

list_display = ('part', 'test_name', 'required')
list_display = ['test_name', 'required']
readonly_fields = ['key']
search_fields = ['test_name']

autocomplete_fields = ('part',)

@admin.register(models.PartTest)
class PartTestAdmin(admin.ModelAdmin):
"""Admin class for the PartTest model."""

list_display = ('template', 'part', 'category')
autocomplete_fields = ('part', 'category', 'template')


@admin.register(models.BomItem)
Expand Down
123 changes: 101 additions & 22 deletions src/backend/InvenTree/part/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
PartSellPriceBreak,
PartStocktake,
PartStocktakeReport,
PartTest,
PartTestTemplate,
)

Expand Down Expand Up @@ -406,28 +407,6 @@ class Meta:
model = PartTestTemplate
fields = ['enabled', 'key', 'required', 'requires_attachment', 'requires_value']

part = rest_filters.ModelChoiceFilter(
queryset=Part.objects.filter(testable=True),
label='Part',
field_name='part',
method='filter_part',
)

def filter_part(self, queryset, name, part):
"""Filter by the 'part' field.

Note: If the 'include_inherited' query parameter is set,
we also include any parts "above" the specified part.
"""
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)

has_results = rest_filters.BooleanFilter(
label=_('Has Results'), method='filter_has_results'
)
Expand Down Expand Up @@ -479,6 +458,94 @@ class PartTestTemplateList(PartTestTemplateMixin, DataExportViewMixin, ListCreat
ordering = 'test_name'


class PartTestFilter(rest_filters.FilterSet):
"""Custom filterset class for the PartTestList endpoint."""

class Meta:
"""Metaclass options for this filterset."""

model = PartTest
fields = ['part', 'template', 'category']

enabled = rest_filters.BooleanFilter(
label=_('Enabled'), field_name='template__enabled'
)

required = rest_filters.BooleanFilter(
label=_('Required'), field_name='template__required'
)

part = rest_filters.ModelChoiceFilter(
queryset=Part.objects.all(), label=_('Part'), method='filter_part'
)

def filter_part(self, queryset, name, part):
"""Filter 'PartTest' instances by the associated 'Part'.

Here, we return all PartTest instances which match:
- The specified part directly
- Any "parent" (template) parts
- Any categories associated with the part
"""
query = Q(part=part)

templates = part.get_ancestors(include_self=True)

query |= Q(part__in=templates)

if part.category:
categories = part.category.get_ancestors(include_self=True)
query |= Q(category__in=categories)

return queryset.filter(query).distinct()

category = rest_filters.ModelChoiceFilter(
queryset=PartCategory.objects.all(),
label=_('Category'),
method='filter_category',
)

def filter_category(self, queryset, name, category):
"""Filter 'PartTest' instances by the associated 'PartCategory'.

Here, we return all PartTest instances which match:
- The specified category directly
- Any parent categories
"""
categories = category.get_ancestors(include_self=True)
return queryset.filter(part__category__in=categories)


class PartTestMixin:
"""Mixin class for the PartTest API endpoints."""

queryset = PartTest.objects.all()
serializer_class = part_serializers.PartTestSerializer

def get_queryset(self, *args, **kwargs):
"""Return an annotated queryset for the PartTestDetail endpoints."""
queryset = super().get_queryset(*args, **kwargs)
queryset = queryset.prefetch_related(
'template', 'category', 'part', 'part__pricing_data'
)
return queryset


class PartTestList(PartTestMixin, ListCreateAPI):
"""List endpoint for the PartTest model."""

filterset_class = PartTestFilter
filter_backends = SEARCH_ORDER_FILTER

search_fields = ['template__test_name', 'template__description']

ordering_fields = ['tempalte', 'part', 'category']


class PartTestDetail(PartTestMixin, RetrieveUpdateDestroyAPI):
"""Detail endpoint for the PartTest model."""


class PartThumbs(ListAPI):
"""API endpoint for retrieving information on available Part thumbnails."""

Expand Down Expand Up @@ -1862,6 +1929,18 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI):
),
]),
),
path(
'test/',
include([
path(
'<int:pk>/',
include([
path('', PartTestDetail.as_view(), name='api-part-test-detail')
]),
),
path('', PartTestList.as_view(), name='api-part-test-list'),
]),
),
# Base URL for part sale pricing
path(
'sale-price/',
Expand Down
68 changes: 68 additions & 0 deletions src/backend/InvenTree/part/migrations/0136_parttest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Generated by Django 4.2.22 on 2025-07-01 13:32

import InvenTree.models
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
("part", "0135_alter_part_link"),
]

operations = [
migrations.CreateModel(
name="PartTest",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"category",
models.ForeignKey(
blank=True,
help_text="Part category to which this test applies",
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="tests",
to="part.partcategory",
verbose_name="Part Category",
),
),
(
"part",
models.ForeignKey(
blank=True,
help_text="Part to which this test applies",
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="tests",
to="part.part",
verbose_name="Part",
),
),
(
"template",
models.ForeignKey(
help_text="Part test template to apply",
on_delete=django.db.models.deletion.CASCADE,
related_name="tests",
to="part.parttesttemplate",
verbose_name="Test Template",
),
),
],
options={
"verbose_name": "Part Test",
"unique_together": {("template", "part"), ("template", "category")},
},
bases=(InvenTree.models.PluginValidationMixin, models.Model),
),
]
119 changes: 119 additions & 0 deletions src/backend/InvenTree/part/migrations/0137_auto_20250701_1334.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Generated by Django 4.2.22 on 2025-07-01 13:34

from django.db import migrations


def template_hash(template):
"""Generate a 'hash' for a PartTestTemplate instance.

This is used to identify similar templates that can be consolidated.

To be eligable for consolidation, the template must be unique across:
- key
- enabled
- required
- requires_value
- requires_attachment
- choices
"""

return f"{template.key}-{template.enabled}-{template.required}-{template.requires_value}-{template.requires_attachment}-{template.choices}"


def migrate_test_template(apps, schema_editor):
"""Migrate PartTestTemplate entries.

- In migration 0136 we added a PartTest model, to replace the PartTestTemplate model
- The PartTest will link either a Part or PartCategory to a PartTestTemplate
- This migration will create PartTest entries for existing PartTestTemplate entries
- It will also attemt to consolidate similar PartTestTemplate entries (where possible)
"""

PartTestTemplate = apps.get_model('part', 'PartTestTemplate')
PartTest = apps.get_model('part', 'PartTest')
StockItemTestResult = apps.get_model('stock', 'StockItemTestResult')

N_RESULTS = StockItemTestResult.objects.count()
N_TEMPLATES = PartTestTemplate.objects.count()

# Get all PartTestTemplate entries
templates = PartTestTemplate.objects.all()

if templates.count() == 0:
return

print(f"\nMigrating {templates.count()} PartTestTemplate entries.")

template_map = dict()
duplicate_count = 0

for template in templates.all():
# Each existing template links to a Part - we need to generate a new PartTest instance
# print("-", template.key, '->', template_hash(template))
key = template_hash(template)

if matching_template := template_map.get(key):
# We have found a duplicate template
# Assign all the StockItemTestResult entries to the matching template
results = StockItemTestResult.objects.filter(template=template)

if results.exists():
results.update(template=matching_template)

# There should be no StockItemTestResult entries linked to the matching template!"
assert StockItemTestResult.objects.filter(template=matching_template).count() == 0

# Now that the results have been migrated, we can delete the old template
template.delete()

duplicate_count += 1
continue

# Create a new PartTest instance for this template
PartTest.objects.create(
template=template,
part=template.part
)

template_map[key] = template

N_PART_TEST = PartTest.objects.count()

# The number of PartTest entries should match the number of unique PartTestTemplate entries
assert N_PART_TEST + duplicate_count == N_TEMPLATES

# The total number of results *MUST* not change
assert StockItemTestResult.objects.count() == N_RESULTS

print(f"- Created {PartTest.objects.count()} PartTest entries.")

if duplicate_count > 0:
print(f"- Removed {duplicate_count} duplicate PartTestTemplate entries.")


def reverse_migrate_test_template(apps, schema_editor):
"""Reverse migration for PartTestTemplate entries.

For each PartTest, we must update the corresponding PartTestTemplate
"""

PartTest = apps.get_model('part', 'PartTest')

for test in PartTest.objects.all():
template = test.tempate
template.part = test.part
template.save()


class Migration(migrations.Migration):

dependencies = [
("part", "0136_parttest"),
]

operations = [
migrations.RunPython(
migrate_test_template,
reverse_code=reverse_migrate_test_template
)
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 4.2.22 on 2025-07-01 14:07

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
("part", "0137_auto_20250701_1334"),
]

operations = [
migrations.RemoveField(
model_name="parttesttemplate",
name="part",
),
]
Loading
Loading