mirror of
https://github.com/inventree/InvenTree.git
synced 2026-02-25 16:17:58 +00:00
[API] Enhanced filtering for generic parameters (#11383)
* Enhanced filtering for parameter templates - Allow filtering by model ID as well as model type * Enhanced filtering for ParameterTemplate - Required for the parameteric data tables - Enable filtering by base model ID - Enable filtering by related model ID * Bump API version * Remove outdated comments * Fix typo * Remove debug statement * Added unit tests * Playwright tests * Fix unit test * Bump requirements * Revert
This commit is contained in:
@@ -1,11 +1,16 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 454
|
INVENTREE_API_VERSION = 455
|
||||||
"""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 = """
|
||||||
|
|
||||||
|
v455 -> 2026-02-19 : https://github.com/inventree/InvenTree/pull/11383
|
||||||
|
- Adds "exists_for_model_id" filter to ParameterTemplate API endpoint
|
||||||
|
- Adds "exists_for_related_model" filter to ParameterTemplate API endpoint
|
||||||
|
- Adds "exists_for_related_model_id" filter to ParameterTemplate API endpoint
|
||||||
|
|
||||||
v454 -> 2026-02-19 : https://github.com/inventree/InvenTree/pull/11379
|
v454 -> 2026-02-19 : https://github.com/inventree/InvenTree/pull/11379
|
||||||
- Adds "purchase_price" ordering option to StockItem API endpoint
|
- Adds "purchase_price" ordering option to StockItem API endpoint
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import json.decoder
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import FieldDoesNotExist, ValidationError
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.http.response import HttpResponse
|
from django.http.response import HttpResponse
|
||||||
@@ -35,6 +35,7 @@ import common.filters
|
|||||||
import common.models
|
import common.models
|
||||||
import common.serializers
|
import common.serializers
|
||||||
import InvenTree.conversion
|
import InvenTree.conversion
|
||||||
|
import InvenTree.models
|
||||||
import InvenTree.ready
|
import InvenTree.ready
|
||||||
from common.icons import get_icon_packs
|
from common.icons import get_icon_packs
|
||||||
from common.settings import get_global_setting
|
from common.settings import get_global_setting
|
||||||
@@ -839,7 +840,13 @@ class ParameterTemplateFilter(FilterSet):
|
|||||||
content_type = common.filters.determine_content_type(value)
|
content_type = common.filters.determine_content_type(value)
|
||||||
|
|
||||||
if not content_type:
|
if not content_type:
|
||||||
return queryset.none()
|
raise ValidationError({
|
||||||
|
'exists_for_model': 'Invalid model type provided - unable to determine content type'
|
||||||
|
})
|
||||||
|
|
||||||
|
# If the 'filter_exists_for_model_id' filter is applied, defer to that
|
||||||
|
if self.request.query_params.get('exists_for_model_id', None):
|
||||||
|
return queryset
|
||||||
|
|
||||||
queryset = queryset.prefetch_related('parameters')
|
queryset = queryset.prefetch_related('parameters')
|
||||||
|
|
||||||
@@ -853,6 +860,160 @@ class ParameterTemplateFilter(FilterSet):
|
|||||||
# Return only those ParameterTemplates which have at least one Parameter for the given model type
|
# Return only those ParameterTemplates which have at least one Parameter for the given model type
|
||||||
return queryset.filter(parameter_count__gt=0)
|
return queryset.filter(parameter_count__gt=0)
|
||||||
|
|
||||||
|
exists_for_model_id = rest_filters.NumberFilter(
|
||||||
|
method='filter_exists_for_model_id', label='Exists For Model ID'
|
||||||
|
)
|
||||||
|
|
||||||
|
def filter_exists_for_model_id(self, queryset, name, value):
|
||||||
|
"""Filter queryset to include only ParameterTemplates which have at least one Parameter for the given model type and model id.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- This filter can only be applied if the 'exists_for_model' filter is also applied, as the model_id is only meaningful in the context of a particular model type.
|
||||||
|
|
||||||
|
Reference: https://github.com/inventree/InvenTree/issues/11381
|
||||||
|
"""
|
||||||
|
exists_for_model = self.request.query_params.get('exists_for_model', None)
|
||||||
|
|
||||||
|
if not exists_for_model:
|
||||||
|
raise ValidationError({
|
||||||
|
'exists_for_model': 'Invalid model type provided - unable to determine content type'
|
||||||
|
})
|
||||||
|
|
||||||
|
content_type = common.filters.determine_content_type(exists_for_model)
|
||||||
|
|
||||||
|
if not content_type:
|
||||||
|
raise ValidationError({
|
||||||
|
'exists_for_model': 'Invalid model type provided - unable to determine content type'
|
||||||
|
})
|
||||||
|
|
||||||
|
model_class = content_type.model_class()
|
||||||
|
|
||||||
|
# Try to find the model instance
|
||||||
|
try:
|
||||||
|
instance = model_class.objects.get(pk=value)
|
||||||
|
except (model_class.DoesNotExist, ValueError):
|
||||||
|
# If the model instance does not exist, then we can return an empty queryset
|
||||||
|
raise ValidationError({
|
||||||
|
'exists_for_model_id': 'Invalid model id provided - no such instance for the given model type'
|
||||||
|
})
|
||||||
|
|
||||||
|
# If the provided model is a "tree" structure, then we should also include any child objects in the filter
|
||||||
|
if isinstance(instance, InvenTree.models.InvenTreeTree):
|
||||||
|
id_values = list(
|
||||||
|
instance.get_descendants(include_self=True).values_list('pk', flat=True)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
id_values = [instance.pk]
|
||||||
|
|
||||||
|
# Now, filter against model type and model id
|
||||||
|
queryset = queryset.prefetch_related('parameters')
|
||||||
|
|
||||||
|
filters = {'model_type': content_type, 'model_id__in': id_values}
|
||||||
|
|
||||||
|
# Annotate the queryset to determine which ParameterTemplates have at least one Parameter defined
|
||||||
|
queryset = queryset.annotate(
|
||||||
|
parameter_count=SubqueryCount('parameters', filter=Q(**filters))
|
||||||
|
)
|
||||||
|
|
||||||
|
return queryset.filter(parameter_count__gt=0)
|
||||||
|
|
||||||
|
exists_for_related_model = rest_filters.CharFilter(
|
||||||
|
method='filter_exists_for_related_model', label='Exists For Related Model'
|
||||||
|
)
|
||||||
|
|
||||||
|
def filter_exists_for_related_model(self, queryset, name, value):
|
||||||
|
"""Filter applied to map parameter templates to a particular model relation against the target model.
|
||||||
|
|
||||||
|
For instance, specify 'category' to filter part parameters which exist for any part in that category.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
- This filter has no effect on its own
|
||||||
|
- It requires the 'exists_for_model' filter to be applied (to specify the base model)
|
||||||
|
- It requires the 'exists_for_related_model_id' filter to be applied also (to specify the related model id)
|
||||||
|
"""
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
exists_for_related_model_id = rest_filters.NumberFilter(
|
||||||
|
method='filter_exists_for_related_model_id', label='Exists For Model ID'
|
||||||
|
)
|
||||||
|
|
||||||
|
def filter_exists_for_related_model_id(self, queryset, name, value):
|
||||||
|
"""Filter queryset to include only ParameterTemplates which have at least one Parameter for the given related model type and model id.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- This filter can only be applied if the 'exists_for_model' filter is also applied, as the model_id is only meaningful in the context of a base model
|
||||||
|
- This filter can only be applied if the 'exists_for_related_model' filter is also applied, as the related model id is only meaningful in the context of a particular model relation
|
||||||
|
|
||||||
|
Example: To filter part parameters which have at least one parameter defined for any part in category 5, you could apply the following filters:
|
||||||
|
- exists_for_model=part
|
||||||
|
- exists_for_related_model=category
|
||||||
|
- exists_for_related_model_id=5
|
||||||
|
"""
|
||||||
|
model = self.request.query_params.get('exists_for_model', None)
|
||||||
|
related_model = self.request.query_params.get('exists_for_related_model', None)
|
||||||
|
|
||||||
|
if not model or not related_model:
|
||||||
|
raise ValidationError({
|
||||||
|
'exists_for_model': 'Invalid model type provided - unable to determine content type'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Determine content type for the base model, to ensure they are valid
|
||||||
|
model_type = common.filters.determine_content_type(model)
|
||||||
|
|
||||||
|
if not model_type:
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
# Determine the model class for the 'related' model
|
||||||
|
try:
|
||||||
|
related_model_field = model_type.model_class()._meta.get_field(
|
||||||
|
related_model
|
||||||
|
)
|
||||||
|
except FieldDoesNotExist:
|
||||||
|
raise ValidationError({
|
||||||
|
'exists_for_related_model': 'Invalid related model - no such field on the base model'
|
||||||
|
})
|
||||||
|
if related_model_field := model_type.model_class()._meta.get_field(
|
||||||
|
related_model
|
||||||
|
):
|
||||||
|
related_model_class = related_model_field.related_model
|
||||||
|
else:
|
||||||
|
# Return an empty queryset if the provided related model is invalid
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
# Find all instances of the related model which match the provided related model id
|
||||||
|
try:
|
||||||
|
related_instance = related_model_class.objects.get(pk=value)
|
||||||
|
except (related_model_class.DoesNotExist, ValueError):
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
# Account for potential tree structure in the related model
|
||||||
|
if isinstance(related_instance, InvenTree.models.InvenTreeTree):
|
||||||
|
related_instances = list(
|
||||||
|
related_instance.get_descendants(include_self=True).values_list(
|
||||||
|
'pk', flat=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
related_instances = [related_instance.pk]
|
||||||
|
|
||||||
|
# Next, find all instances of the base model which are related to the related model instances
|
||||||
|
model_instances = model_type.model_class().objects.filter(**{
|
||||||
|
f'{related_model}__in': related_instances
|
||||||
|
})
|
||||||
|
model_instance_ids = list(model_instances.values_list('pk', flat=True))
|
||||||
|
|
||||||
|
# Now, filter against model type and model id
|
||||||
|
queryset = queryset.prefetch_related('parameters')
|
||||||
|
|
||||||
|
filters = {'model_type': model_type, 'model_id__in': model_instance_ids}
|
||||||
|
|
||||||
|
# Annotate the queryset to determine which ParameterTemplates have at least one Parameter defined
|
||||||
|
queryset = queryset.annotate(
|
||||||
|
parameter_count=SubqueryCount('parameters', filter=Q(**filters))
|
||||||
|
)
|
||||||
|
|
||||||
|
return queryset.filter(parameter_count__gt=0)
|
||||||
|
|
||||||
|
|
||||||
class ParameterTemplateMixin:
|
class ParameterTemplateMixin:
|
||||||
"""Mixin class for ParameterTemplate views."""
|
"""Mixin class for ParameterTemplate views."""
|
||||||
|
|||||||
@@ -237,6 +237,217 @@ class ParameterAPITests(InvenTreeAPITestCase):
|
|||||||
f'Incorrect number of templates for model "{model_name}"',
|
f'Incorrect number of templates for model "{model_name}"',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_template_extended_filters(self):
|
||||||
|
"""Unit testing for more complex filters on the ParameterTemplate endpoint.
|
||||||
|
|
||||||
|
Ref: https://github.com/inventree/InvenTree/pull/11383
|
||||||
|
|
||||||
|
In these tests we will filter by complex model relations.
|
||||||
|
"""
|
||||||
|
from part.models import Part, PartCategory
|
||||||
|
|
||||||
|
# Create some part categories
|
||||||
|
cat_mech = PartCategory.objects.create(
|
||||||
|
name='Mechanical', description='Mechanical components'
|
||||||
|
)
|
||||||
|
cat_elec = PartCategory.objects.create(
|
||||||
|
name='Electronics', description='Electronic components'
|
||||||
|
)
|
||||||
|
cat_pass = PartCategory.objects.create(
|
||||||
|
name='Passive', description='Passive electronic components', parent=cat_elec
|
||||||
|
)
|
||||||
|
cat_res = PartCategory.objects.create(
|
||||||
|
name='Resistors', description='Resistor components', parent=cat_pass
|
||||||
|
)
|
||||||
|
cat_cap = PartCategory.objects.create(
|
||||||
|
name='Capacitors', description='Capacitor components', parent=cat_pass
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create some parts
|
||||||
|
capacitors = [
|
||||||
|
Part.objects.create(
|
||||||
|
name=f'Capacitor {ii}', description='A capacitor', category=cat_cap
|
||||||
|
)
|
||||||
|
for ii in range(5)
|
||||||
|
]
|
||||||
|
|
||||||
|
resistors = [
|
||||||
|
Part.objects.create(
|
||||||
|
name=f'Resistor {ii}', description='A resistor', category=cat_res
|
||||||
|
)
|
||||||
|
for ii in range(5)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Create some ParameterTemplates which relate to the category of the part
|
||||||
|
resistance = common.models.ParameterTemplate.objects.create(
|
||||||
|
name='Resistance', description='The resistance of a part', units='Ohms'
|
||||||
|
)
|
||||||
|
|
||||||
|
capacitance = common.models.ParameterTemplate.objects.create(
|
||||||
|
name='Capacitance', description='The capacitance of a part', units='Farads'
|
||||||
|
)
|
||||||
|
|
||||||
|
tolerance = common.models.ParameterTemplate.objects.create(
|
||||||
|
name='Tolerance', description='The tolerance of a part', units='%'
|
||||||
|
)
|
||||||
|
|
||||||
|
for idx, resistor in enumerate(resistors):
|
||||||
|
common.models.Parameter.objects.create(
|
||||||
|
template=resistance,
|
||||||
|
model_type=resistor.get_content_type(),
|
||||||
|
model_id=resistor.pk,
|
||||||
|
data=f'{10 * (idx + 1)}k',
|
||||||
|
)
|
||||||
|
|
||||||
|
common.models.Parameter.objects.create(
|
||||||
|
template=tolerance,
|
||||||
|
model_type=resistor.get_content_type(),
|
||||||
|
model_id=resistor.pk,
|
||||||
|
data=f'{idx + 1}%',
|
||||||
|
)
|
||||||
|
|
||||||
|
for idx, capacitor in enumerate(capacitors):
|
||||||
|
common.models.Parameter.objects.create(
|
||||||
|
template=capacitance,
|
||||||
|
model_type=capacitor.get_content_type(),
|
||||||
|
model_id=capacitor.pk,
|
||||||
|
data=f'{10 * (idx + 1)}uF',
|
||||||
|
)
|
||||||
|
|
||||||
|
common.models.Parameter.objects.create(
|
||||||
|
template=tolerance,
|
||||||
|
model_type=capacitor.get_content_type(),
|
||||||
|
model_id=capacitor.pk,
|
||||||
|
data=f'{5 * (idx + 1)}%',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure that we have the expected number of templates and parameters created for testing
|
||||||
|
self.assertEqual(common.models.ParameterTemplate.objects.count(), 3)
|
||||||
|
self.assertEqual(common.models.Parameter.objects.count(), 20)
|
||||||
|
|
||||||
|
# Now, we have some data - let's apply some filtering
|
||||||
|
url = reverse('api-parameter-template-list')
|
||||||
|
|
||||||
|
# Return *all* results, without filters
|
||||||
|
response = self.get(url)
|
||||||
|
self.assertEqual(len(response.data), 3)
|
||||||
|
|
||||||
|
# Filter by 'exists_for_model'
|
||||||
|
for model_name, count in {
|
||||||
|
'part.part': 3,
|
||||||
|
'part': 3,
|
||||||
|
'company': 0,
|
||||||
|
'build': 0,
|
||||||
|
}.items():
|
||||||
|
response = self.get(url, data={'exists_for_model': model_name})
|
||||||
|
n = len(response.data)
|
||||||
|
self.assertEqual(
|
||||||
|
n,
|
||||||
|
count,
|
||||||
|
f'Incorrect number of templates ({n}) for model "{model_name}"',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter by 'exists_for_model' and 'exists_for_model_id'
|
||||||
|
res = resistors[0]
|
||||||
|
response = self.get(
|
||||||
|
url, data={'exists_for_model': 'part.part', 'exists_for_model_id': res.pk}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(response.data), 2)
|
||||||
|
pk_list = [t['pk'] for t in response.data]
|
||||||
|
self.assertIn(resistance.pk, pk_list)
|
||||||
|
self.assertIn(tolerance.pk, pk_list)
|
||||||
|
|
||||||
|
cap = capacitors[0]
|
||||||
|
response = self.get(
|
||||||
|
url, data={'exists_for_model': 'part.part', 'exists_for_model_id': cap.pk}
|
||||||
|
)
|
||||||
|
self.assertEqual(len(response.data), 2)
|
||||||
|
pk_list = [t['pk'] for t in response.data]
|
||||||
|
self.assertIn(capacitance.pk, pk_list)
|
||||||
|
self.assertIn(tolerance.pk, pk_list)
|
||||||
|
|
||||||
|
# Filter by 'exists_for_related_model' (test the "capacitor" relationship)
|
||||||
|
|
||||||
|
# Check the 'capacitor' category
|
||||||
|
response = self.get(
|
||||||
|
url,
|
||||||
|
data={
|
||||||
|
'exists_for_model': 'part.part',
|
||||||
|
'exists_for_related_model': 'category',
|
||||||
|
'exists_for_related_model_id': cat_cap.pk,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(response.data), 2)
|
||||||
|
pk_list = [t['pk'] for t in response.data]
|
||||||
|
self.assertIn(capacitance.pk, pk_list)
|
||||||
|
self.assertIn(tolerance.pk, pk_list)
|
||||||
|
|
||||||
|
# Check the 'electronics' category - this should return all parameters
|
||||||
|
response = self.get(
|
||||||
|
url,
|
||||||
|
data={
|
||||||
|
'exists_for_model': 'part.part',
|
||||||
|
'exists_for_related_model': 'category',
|
||||||
|
'exists_for_related_model_id': cat_elec.pk,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(len(response.data), 3)
|
||||||
|
pk_list = [t['pk'] for t in response.data]
|
||||||
|
self.assertIn(resistance.pk, pk_list)
|
||||||
|
self.assertIn(capacitance.pk, pk_list)
|
||||||
|
self.assertIn(tolerance.pk, pk_list)
|
||||||
|
|
||||||
|
# Check the 'mechanical' category - this should return no parameters
|
||||||
|
response = self.get(
|
||||||
|
url,
|
||||||
|
data={
|
||||||
|
'exists_for_model': 'part.part',
|
||||||
|
'exists_for_related_model': 'category',
|
||||||
|
'exists_for_related_model_id': cat_mech.pk,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(response.data), 0)
|
||||||
|
|
||||||
|
def test_invalid_filters(self):
|
||||||
|
"""Test error messages for invalid filter combinations."""
|
||||||
|
url = reverse('api-parameter-template-list')
|
||||||
|
|
||||||
|
# Invalid 'exists_for_model' value
|
||||||
|
response = self.get(
|
||||||
|
url, {'exists_for_model': 'asdf---invalid---model'}, expected_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
'Invalid model type provided', str(response.data['exists_for_model'])
|
||||||
|
)
|
||||||
|
|
||||||
|
# Invalid 'exists_for_model_id' value
|
||||||
|
for model_id in ['not_an_integer', -1, 9999]:
|
||||||
|
response = self.get(
|
||||||
|
url,
|
||||||
|
{'exists_for_model': 'part.part', 'exists_for_model_id': model_id},
|
||||||
|
expected_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Invalid 'exists_for_related_model' value
|
||||||
|
response = self.get(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
'exists_for_model': 'part',
|
||||||
|
'exists_for_related_model': 'invalid_field',
|
||||||
|
'exists_for_related_model_id': 1,
|
||||||
|
},
|
||||||
|
expected_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
'no such field on the base model',
|
||||||
|
str(response.data['exists_for_related_model']),
|
||||||
|
)
|
||||||
|
|
||||||
def test_parameter_api(self):
|
def test_parameter_api(self):
|
||||||
"""Test Parameter API functionality."""
|
"""Test Parameter API functionality."""
|
||||||
# Create a simple part to test with
|
# Create a simple part to test with
|
||||||
|
|||||||
@@ -1659,9 +1659,9 @@ pynacl==1.6.2 \
|
|||||||
# via
|
# via
|
||||||
# -c src/backend/requirements.txt
|
# -c src/backend/requirements.txt
|
||||||
# paramiko
|
# paramiko
|
||||||
pypdf==6.7.1 \
|
pypdf==6.6.2 \
|
||||||
--hash=sha256:6b7a63be5563a0a35d54c6d6b550d75c00b8ccf36384be96365355e296e6b3b0 \
|
--hash=sha256:0a3ea3b3303982333404e22d8f75d7b3144f9cf4b2970b96856391a516f9f016 \
|
||||||
--hash=sha256:a02ccbb06463f7c334ce1612e91b3e68a8e827f3cee100b9941771e6066b094e
|
--hash=sha256:44c0c9811cfb3b83b28f1c3d054531d5b8b81abaedee0d8cb403650d023832ba
|
||||||
# via
|
# via
|
||||||
# -c src/backend/requirements.txt
|
# -c src/backend/requirements.txt
|
||||||
# -r src/backend/requirements.in
|
# -r src/backend/requirements.in
|
||||||
|
|||||||
@@ -101,12 +101,18 @@ function ParameterCell({
|
|||||||
*/
|
*/
|
||||||
export default function ParametricDataTable({
|
export default function ParametricDataTable({
|
||||||
modelType,
|
modelType,
|
||||||
|
modelId,
|
||||||
|
relatedModel,
|
||||||
|
relatedModelId,
|
||||||
endpoint,
|
endpoint,
|
||||||
queryParams,
|
queryParams,
|
||||||
customFilters,
|
customFilters,
|
||||||
customColumns
|
customColumns
|
||||||
}: {
|
}: {
|
||||||
modelType: ModelType;
|
modelType: ModelType;
|
||||||
|
modelId?: number;
|
||||||
|
relatedModel?: string;
|
||||||
|
relatedModelId?: number;
|
||||||
endpoint: ApiEndpoints | string;
|
endpoint: ApiEndpoints | string;
|
||||||
queryParams?: Record<string, any>;
|
queryParams?: Record<string, any>;
|
||||||
customFilters?: TableFilter[];
|
customFilters?: TableFilter[];
|
||||||
@@ -125,8 +131,12 @@ export default function ParametricDataTable({
|
|||||||
.get(apiUrl(ApiEndpoints.parameter_template_list), {
|
.get(apiUrl(ApiEndpoints.parameter_template_list), {
|
||||||
params: {
|
params: {
|
||||||
active: true,
|
active: true,
|
||||||
|
ordering: 'name',
|
||||||
for_model: modelType,
|
for_model: modelType,
|
||||||
exists_for_model: modelType
|
exists_for_model: modelType,
|
||||||
|
exists_for_model_id: modelId ?? undefined,
|
||||||
|
exists_for_related_model: relatedModel ?? undefined,
|
||||||
|
exists_for_related_model_id: relatedModelId ?? undefined
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then((response) => response.data);
|
.then((response) => response.data);
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ export default function ParametricPartTable({
|
|||||||
return (
|
return (
|
||||||
<ParametricDataTable
|
<ParametricDataTable
|
||||||
modelType={ModelType.part}
|
modelType={ModelType.part}
|
||||||
|
relatedModel={'category'}
|
||||||
|
relatedModelId={categoryId}
|
||||||
endpoint={ApiEndpoints.part_list}
|
endpoint={ApiEndpoints.part_list}
|
||||||
customColumns={customColumns}
|
customColumns={customColumns}
|
||||||
customFilters={customFilters}
|
customFilters={customFilters}
|
||||||
|
|||||||
@@ -180,3 +180,33 @@ export const toggleColumnSorting = async (page: Page, columnName: string) => {
|
|||||||
await page.waitForTimeout(50);
|
await page.waitForTimeout(50);
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Display the 'table' view
|
||||||
|
export const showTableView = async (page: Page) => {
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'segmented-icon-control-table' })
|
||||||
|
.click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Display the 'parameteric' view
|
||||||
|
export const showParametricView = async (page: Page) => {
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'segmented-icon-control-parametric' })
|
||||||
|
.click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Display the 'calendar' view
|
||||||
|
export const showCalendarView = async (page: Page) => {
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: 'segmented-icon-control-calendar' })
|
||||||
|
.click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check for an expected number of columns in the visible table
|
||||||
|
export const expectTableColumnCount = async (page: Page, count: number) => {
|
||||||
|
const columns = page.locator('table thead tr th');
|
||||||
|
await expect(columns).toHaveCount(count);
|
||||||
|
};
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import {
|
|||||||
getRowFromCell,
|
getRowFromCell,
|
||||||
loadTab,
|
loadTab,
|
||||||
navigate,
|
navigate,
|
||||||
setTableChoiceFilter
|
setTableChoiceFilter,
|
||||||
|
showCalendarView,
|
||||||
|
showParametricView,
|
||||||
|
showTableView
|
||||||
} from '../helpers.ts';
|
} from '../helpers.ts';
|
||||||
import { doCachedLogin } from '../login.ts';
|
import { doCachedLogin } from '../login.ts';
|
||||||
|
|
||||||
@@ -17,18 +20,10 @@ test('Build - Index', async ({ browser }) => {
|
|||||||
await loadTab(page, 'Build Orders');
|
await loadTab(page, 'Build Orders');
|
||||||
|
|
||||||
// Ensure all data views are available
|
// Ensure all data views are available
|
||||||
await page
|
await showParametricView(page);
|
||||||
.getByRole('button', { name: 'segmented-icon-control-parametric' })
|
await showCalendarView(page);
|
||||||
.click();
|
|
||||||
|
|
||||||
await page
|
|
||||||
.getByRole('button', { name: 'segmented-icon-control-calendar' })
|
|
||||||
.click();
|
|
||||||
await page.getByRole('button', { name: 'action-button-next-month' }).click();
|
await page.getByRole('button', { name: 'action-button-next-month' }).click();
|
||||||
|
await showTableView(page);
|
||||||
await page
|
|
||||||
.getByRole('button', { name: 'segmented-icon-control-table' })
|
|
||||||
.click();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Build Order - Basic Tests', async ({ browser }) => {
|
test('Build Order - Basic Tests', async ({ browser }) => {
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { test } from '../baseFixtures.js';
|
import { test } from '../baseFixtures.js';
|
||||||
import { clickOnParamFilter, loadTab, navigate } from '../helpers.js';
|
import {
|
||||||
|
clickOnParamFilter,
|
||||||
|
loadTab,
|
||||||
|
navigate,
|
||||||
|
showParametricView
|
||||||
|
} from '../helpers.js';
|
||||||
import { doCachedLogin } from '../login.js';
|
import { doCachedLogin } from '../login.js';
|
||||||
|
|
||||||
test('Company', async ({ browser }) => {
|
test('Company', async ({ browser }) => {
|
||||||
@@ -49,9 +54,7 @@ test('Company - Parameters', async ({ browser }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Show parametric view
|
// Show parametric view
|
||||||
await page
|
await showParametricView(page);
|
||||||
.getByRole('button', { name: 'segmented-icon-control-parametric' })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
// Filter by "payment terms" parameter value
|
// Filter by "payment terms" parameter value
|
||||||
await clickOnParamFilter(page, 'Payment Terms');
|
await clickOnParamFilter(page, 'Payment Terms');
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import {
|
|||||||
clickOnParamFilter,
|
clickOnParamFilter,
|
||||||
clickOnRowMenu,
|
clickOnRowMenu,
|
||||||
deletePart,
|
deletePart,
|
||||||
|
expectTableColumnCount,
|
||||||
getRowFromCell,
|
getRowFromCell,
|
||||||
loadTab,
|
loadTab,
|
||||||
navigate,
|
navigate,
|
||||||
setTableChoiceFilter
|
setTableChoiceFilter,
|
||||||
|
showParametricView,
|
||||||
|
showTableView
|
||||||
} from '../helpers';
|
} from '../helpers';
|
||||||
import { doCachedLogin } from '../login';
|
import { doCachedLogin } from '../login';
|
||||||
import { setPluginState, setSettingState } from '../settings';
|
import { setPluginState, setSettingState } from '../settings';
|
||||||
@@ -499,6 +502,58 @@ test('Parts - Attachments', async ({ browser }) => {
|
|||||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Parts - Parameters by Category', async ({ browser }) => {
|
||||||
|
const page = await doCachedLogin(browser, { url: 'part/category/4/parts' });
|
||||||
|
|
||||||
|
await showParametricView(page);
|
||||||
|
|
||||||
|
// Check for expected parameter columns
|
||||||
|
for (const col of [
|
||||||
|
'Total Stock',
|
||||||
|
'Capacitance [F]',
|
||||||
|
'Power [W]',
|
||||||
|
'Resistance [ohms]'
|
||||||
|
]) {
|
||||||
|
await page.getByRole('button', { name: col }).waitFor();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expectTableColumnCount(page, 9);
|
||||||
|
|
||||||
|
// Now let's go to the "resistors" category
|
||||||
|
await navigate(page, 'part/category/5/parts');
|
||||||
|
await showParametricView(page);
|
||||||
|
|
||||||
|
// Fewer parameter templates displayed here
|
||||||
|
await expectTableColumnCount(page, 7);
|
||||||
|
|
||||||
|
// Check for expected parameter columns
|
||||||
|
for (const col of [
|
||||||
|
'Total Stock',
|
||||||
|
'Tolerance [percent]',
|
||||||
|
'Power [W]',
|
||||||
|
'Resistance [ohms]'
|
||||||
|
]) {
|
||||||
|
await page.getByRole('button', { name: col }).waitFor();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally, let's go to the "capacitors" category, which has a different set of parameter templates
|
||||||
|
await navigate(page, 'part/category/6/parts');
|
||||||
|
await showParametricView(page);
|
||||||
|
await expectTableColumnCount(page, 7);
|
||||||
|
|
||||||
|
for (const col of [
|
||||||
|
'Total Stock',
|
||||||
|
'Tolerance [percent]',
|
||||||
|
'Polarized',
|
||||||
|
'Capacitance [F]'
|
||||||
|
]) {
|
||||||
|
await page.getByRole('button', { name: col }).waitFor();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset to the table view
|
||||||
|
await showTableView(page);
|
||||||
|
});
|
||||||
|
|
||||||
test('Parts - Parameters', async ({ browser }) => {
|
test('Parts - Parameters', async ({ browser }) => {
|
||||||
const page = await doCachedLogin(browser, { url: 'part/69/parameters' });
|
const page = await doCachedLogin(browser, { url: 'part/69/parameters' });
|
||||||
|
|
||||||
@@ -562,10 +617,8 @@ test('Parts - Parameter Filtering', async ({ browser }) => {
|
|||||||
const page = await doCachedLogin(browser, { url: 'part/' });
|
const page = await doCachedLogin(browser, { url: 'part/' });
|
||||||
|
|
||||||
await loadTab(page, 'Parts', true);
|
await loadTab(page, 'Parts', true);
|
||||||
await page
|
|
||||||
.getByRole('button', { name: 'segmented-icon-control-parametric' })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
|
await showParametricView(page);
|
||||||
await clearTableFilters(page);
|
await clearTableFilters(page);
|
||||||
|
|
||||||
// All parts should be available (no filters applied)
|
// All parts should be available (no filters applied)
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import {
|
|||||||
loadTab,
|
loadTab,
|
||||||
navigate,
|
navigate,
|
||||||
openFilterDrawer,
|
openFilterDrawer,
|
||||||
setTableChoiceFilter
|
setTableChoiceFilter,
|
||||||
|
showCalendarView,
|
||||||
|
showParametricView,
|
||||||
|
showTableView
|
||||||
} from '../helpers.ts';
|
} from '../helpers.ts';
|
||||||
import { doCachedLogin } from '../login.ts';
|
import { doCachedLogin } from '../login.ts';
|
||||||
|
|
||||||
@@ -18,26 +21,14 @@ test('Purchasing - Index', async ({ browser }) => {
|
|||||||
|
|
||||||
// Purchase Orders tab
|
// Purchase Orders tab
|
||||||
await loadTab(page, 'Purchase Orders');
|
await loadTab(page, 'Purchase Orders');
|
||||||
await page
|
await showParametricView(page);
|
||||||
.getByRole('button', { name: 'segmented-icon-control-parametric' })
|
await showCalendarView(page);
|
||||||
.click();
|
await showTableView(page);
|
||||||
|
|
||||||
await page
|
|
||||||
.getByRole('button', { name: 'segmented-icon-control-calendar' })
|
|
||||||
.click();
|
|
||||||
await page.getByRole('button', { name: 'calendar-select-month' }).waitFor();
|
|
||||||
await page
|
|
||||||
.getByRole('button', { name: 'segmented-icon-control-table' })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
// Suppliers tab
|
// Suppliers tab
|
||||||
await loadTab(page, 'Suppliers');
|
await loadTab(page, 'Suppliers');
|
||||||
await page
|
await showParametricView(page);
|
||||||
.getByRole('button', { name: 'segmented-icon-control-parametric' })
|
await showTableView(page);
|
||||||
.click();
|
|
||||||
await page
|
|
||||||
.getByRole('button', { name: 'segmented-icon-control-table' })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
// Check for expected values
|
// Check for expected values
|
||||||
await clearTableFilters(page);
|
await clearTableFilters(page);
|
||||||
@@ -45,12 +36,8 @@ test('Purchasing - Index', async ({ browser }) => {
|
|||||||
|
|
||||||
// Supplier parts tab
|
// Supplier parts tab
|
||||||
await loadTab(page, 'Supplier Parts');
|
await loadTab(page, 'Supplier Parts');
|
||||||
await page
|
await showParametricView(page);
|
||||||
.getByRole('button', { name: 'segmented-icon-control-parametric' })
|
await showTableView(page);
|
||||||
.click();
|
|
||||||
await page
|
|
||||||
.getByRole('button', { name: 'segmented-icon-control-table' })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
// Check for expected values
|
// Check for expected values
|
||||||
await clearTableFilters(page);
|
await clearTableFilters(page);
|
||||||
@@ -60,12 +47,8 @@ test('Purchasing - Index', async ({ browser }) => {
|
|||||||
|
|
||||||
// Manufacturers tab
|
// Manufacturers tab
|
||||||
await loadTab(page, 'Manufacturers');
|
await loadTab(page, 'Manufacturers');
|
||||||
await page
|
await showParametricView(page);
|
||||||
.getByRole('button', { name: 'segmented-icon-control-parametric' })
|
await showTableView(page);
|
||||||
.click();
|
|
||||||
await page
|
|
||||||
.getByRole('button', { name: 'segmented-icon-control-table' })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
// Check for expected values
|
// Check for expected values
|
||||||
await clearTableFilters(page);
|
await clearTableFilters(page);
|
||||||
@@ -76,12 +59,8 @@ test('Purchasing - Index', async ({ browser }) => {
|
|||||||
|
|
||||||
// Manufacturer parts tab
|
// Manufacturer parts tab
|
||||||
await loadTab(page, 'Manufacturer Parts');
|
await loadTab(page, 'Manufacturer Parts');
|
||||||
await page
|
await showParametricView(page);
|
||||||
.getByRole('button', { name: 'segmented-icon-control-parametric' })
|
await showTableView(page);
|
||||||
.click();
|
|
||||||
await page
|
|
||||||
.getByRole('button', { name: 'segmented-icon-control-table' })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
// Check for expected values
|
// Check for expected values
|
||||||
await clearTableFilters(page);
|
await clearTableFilters(page);
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import {
|
|||||||
clickOnRowMenu,
|
clickOnRowMenu,
|
||||||
globalSearch,
|
globalSearch,
|
||||||
loadTab,
|
loadTab,
|
||||||
setTableChoiceFilter
|
setTableChoiceFilter,
|
||||||
|
showCalendarView,
|
||||||
|
showParametricView,
|
||||||
|
showTableView
|
||||||
} from '../helpers.ts';
|
} from '../helpers.ts';
|
||||||
import { doCachedLogin } from '../login.ts';
|
import { doCachedLogin } from '../login.ts';
|
||||||
|
|
||||||
@@ -18,15 +21,9 @@ test('Sales Orders - Tabs', async ({ browser }) => {
|
|||||||
await loadTab(page, 'Sales Orders');
|
await loadTab(page, 'Sales Orders');
|
||||||
await page.waitForURL('**/web/sales/index/salesorders');
|
await page.waitForURL('**/web/sales/index/salesorders');
|
||||||
|
|
||||||
await page
|
await showParametricView(page);
|
||||||
.getByRole('button', { name: 'segmented-icon-control-parametric' })
|
await showCalendarView(page);
|
||||||
.click();
|
await showTableView(page);
|
||||||
await page
|
|
||||||
.getByRole('button', { name: 'segmented-icon-control-calendar' })
|
|
||||||
.click();
|
|
||||||
await page
|
|
||||||
.getByRole('button', { name: 'segmented-icon-control-table' })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
// Pending Shipments panel
|
// Pending Shipments panel
|
||||||
await loadTab(page, 'Pending Shipments');
|
await loadTab(page, 'Pending Shipments');
|
||||||
@@ -37,25 +34,15 @@ test('Sales Orders - Tabs', async ({ browser }) => {
|
|||||||
await loadTab(page, 'Return Orders');
|
await loadTab(page, 'Return Orders');
|
||||||
await page.getByRole('cell', { name: 'NOISE-COMPLAINT' }).waitFor();
|
await page.getByRole('cell', { name: 'NOISE-COMPLAINT' }).waitFor();
|
||||||
|
|
||||||
await page
|
await showParametricView(page);
|
||||||
.getByRole('button', { name: 'segmented-icon-control-parametric' })
|
await showCalendarView(page);
|
||||||
.click();
|
await showTableView(page);
|
||||||
await page
|
|
||||||
.getByRole('button', { name: 'segmented-icon-control-calendar' })
|
|
||||||
.click();
|
|
||||||
await page
|
|
||||||
.getByRole('button', { name: 'segmented-icon-control-table' })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
// Customers
|
// Customers
|
||||||
await loadTab(page, 'Customers');
|
await loadTab(page, 'Customers');
|
||||||
|
|
||||||
await page
|
await showParametricView(page);
|
||||||
.getByRole('button', { name: 'segmented-icon-control-parametric' })
|
await showTableView(page);
|
||||||
.click();
|
|
||||||
await page
|
|
||||||
.getByRole('button', { name: 'segmented-icon-control-table' })
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await page.getByText('Customer A').click();
|
await page.getByText('Customer A').click();
|
||||||
await loadTab(page, 'Notes');
|
await loadTab(page, 'Notes');
|
||||||
|
|||||||
Reference in New Issue
Block a user