mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Revision Improvements (#7585)
* Bump djangorestframework from 3.14.0 to 3.15.2 in /src/backend Bumps [djangorestframework](https://github.com/encode/django-rest-framework) from 3.14.0 to 3.15.2. - [Release notes](https://github.com/encode/django-rest-framework/releases) - [Commits](https://github.com/encode/django-rest-framework/compare/3.14.0...3.15.2) --- updated-dependencies: - dependency-name: djangorestframework dependency-type: direct:production ... Signed-off-by: dependabot[bot] <support@github.com> * fix req * fix deps again * patch serializer * bump api version * Fix "min_value" for DRF decimal fields * Add default serializer values for 'IPN' and 'revision' * Add specific serializer for email field * Fix API version * Add 'revision_of' field to Part model * Add validation checks for new revision_of field * Update migration * Add unit test for 'revision' rules * Add API filters for revision control * Add table filters for PUI * Add "revision_of" field to PUI form * Update part forms for PUI * Render part revision selection dropdown in PUI * Prevent refetch on focus * Ensure select renders above other items * Disable searching * Cleanup <PartDetail/> * UI tweak * Add setting to control revisions for assemblies * Hide revision selection drop-down if revisions are not enabled * Query updates * Validate entire BOM table from PUI * Sort revisions * Fix requirements files * Fix api_version.py * Reintroduce previous check for IPN / revision uniqueness * Set default value for refetchOnWindowFocus (false) * Revert serializer change * Further CI fixes * Further unit test updates * Fix defaults for query client * Add docs * Add link to "revision_of" in CUI * Add playwright test for revisions * Ignore notification errors for playwright --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
parent
fb17078497
commit
767b76314e
BIN
docs/docs/assets/images/part/part_create_revision.png
Normal file
BIN
docs/docs/assets/images/part/part_create_revision.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 68 KiB |
BIN
docs/docs/assets/images/part/part_revision_b.png
Normal file
BIN
docs/docs/assets/images/part/part_revision_b.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
BIN
docs/docs/assets/images/part/part_revision_select.png
Normal file
BIN
docs/docs/assets/images/part/part_revision_select.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 33 KiB |
BIN
docs/docs/assets/images/part/part_revision_settings.png
Normal file
BIN
docs/docs/assets/images/part/part_revision_settings.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.4 KiB |
78
docs/docs/part/revision.md
Normal file
78
docs/docs/part/revision.md
Normal file
@ -0,0 +1,78 @@
|
||||
---
|
||||
title: Part Revisions
|
||||
---
|
||||
|
||||
## Part Revisions
|
||||
|
||||
When creating a complex part (such as an assembly comprised of other parts), it is often necessary to track changes to the part over time. For example, throughout the lifetime of an assembly, it may be necessary to adjust the bill of materials, or update the design of the part.
|
||||
|
||||
Rather than overwrite the existing part data, InvenTree allows you to create a new *revision* of the part. This allows you to track changes to the part over time, and maintain a history of the part design.
|
||||
|
||||
Crucially, creating a new *revision* ensures that any related data entries which refer to the original part (such as stock items, build orders, purchase orders, etc) are not affected by the change.
|
||||
|
||||
### Revisions are Parts
|
||||
|
||||
A *revision* of a part is itself a part. This means that each revision of a part has its own part number, stock items, parameters, bill of materials, etc. The only thing that differentiates a *revision* from any other part is that the *revision* is linked to the original part.
|
||||
|
||||
### Revision Fields
|
||||
|
||||
Each part has two fields which are used to track the revision of the part:
|
||||
|
||||
* **Revision**: The revision number of the part. This is a user-defined field, and can be any string value.
|
||||
* **Revision Of**: A reference to the part of which *this* part is a revision. This field is used to keep track of the available revisions for any particular part.
|
||||
|
||||
### Revision Restrictions
|
||||
|
||||
When creating a new revision of a part, there are some restrictions which must be adhered to:
|
||||
|
||||
* **Circular References**: A part cannot be a revision of itself. This would create a circular reference which is not allowed.
|
||||
* **Unique Revisions**: A part cannot have two revisions with the same revision number. Each revision (of a given part) must have a unique revision code.
|
||||
* **Revisions of Revisions**: A single part can have multiple revisions, but a revision cannot have its own revision. This restriction is in place to prevent overly complex part relationships.
|
||||
* **Template Revisions**: A part which is a [template part](./template.md) cannot have revisions. This is because the template part is used to create variants, and allowing revisions of templates would create disallowed relationship states in the database. However, variant parts are allowed to have revisions.
|
||||
* **Template References**: A part which is a revision of a variant part must point to the same template as the original part. This is to ensure that the revision is correctly linked to the original part.
|
||||
|
||||
## Revision Settings
|
||||
|
||||
The following options are available to control the behavior of part revisions.
|
||||
|
||||
Note that these options can be changed in the InvenTree settings:
|
||||
|
||||
{% with id="part_revision_settings", url="part/part_revision_settings.png", description="Part revision settings" %}
|
||||
{% include 'img.html' %}
|
||||
{% endwith %}
|
||||
|
||||
* **Enable Revisions**: If this setting is enabled, parts can have revisions. If this setting is disabled, parts cannot have revisions.
|
||||
* **Assembly Revisions Only**: If this setting is enabled, only assembly parts can have revisions. This is useful if you only want to track revisions of assemblies, and not individual parts.
|
||||
|
||||
## Create a Revision
|
||||
|
||||
To create a new revision for a given part, navigate to the part detail page, and click on the "Revisions" tab.
|
||||
|
||||
Select the "Duplicate Part" action, to create a new copy of the selected part. This will open the "Duplicate Part" form:
|
||||
|
||||
{% with id="part_create_revision", url="part/part_create_revision.png", description="Create part revision" %}
|
||||
{% include 'img.html' %}
|
||||
{% endwith %}
|
||||
|
||||
In this form, make the following updates:
|
||||
|
||||
1. Set the *Revision Of* field to the original part (the one that you are duplicating)
|
||||
2. Set the *Revision* field to a unique revision number for the new part revision
|
||||
|
||||
Once these changes (and any other required changes) are made, press *Submit* to create the new part.
|
||||
|
||||
Once the form is submitted (without any errors), you will be redirected to the new part revision. Here you can see that it is linked to the original part:
|
||||
|
||||
{% with id="part_revision_b", url="part/part_revision_b.png", description="Revision B" %}
|
||||
{% include 'img.html' %}
|
||||
{% endwith %}
|
||||
|
||||
## Revision Navigation
|
||||
|
||||
When multiple revisions exist for a particular part, you can navigate between revisions using the *Select Part Revision* drop-down which renders at the top of the part page:
|
||||
|
||||
{% with id="part_revision_select", url="part/part_revision_select.png", description="Select part revision" %}
|
||||
{% include 'img.html' %}
|
||||
{% endwith %}
|
||||
|
||||
Note that this revision selector is only visible when multiple revisions exist for the part.
|
@ -28,7 +28,6 @@ Details provides information about the particular part. Parts details can be dis
|
||||
{% with id="part_overview", url="part/part_overview.png", description="Part details" %}
|
||||
{% include 'img.html' %}
|
||||
{% endwith %}
|
||||
<p></p>
|
||||
|
||||
A Part is defined in the system by the following parameters:
|
||||
|
||||
@ -38,7 +37,7 @@ A Part is defined in the system by the following parameters:
|
||||
|
||||
**Description** - Longer form text field describing the Part
|
||||
|
||||
**Revision** - An optional revision code denoting the particular version for the part. Used when there are multiple revisions of the same master part object.
|
||||
**Revision** - An optional revision code denoting the particular version for the part. Used when there are multiple revisions of the same master part object. Read [more about part revisions here](./revision.md).
|
||||
|
||||
**Keywords** - Optional few words to describe the part and make the part search more efficient.
|
||||
|
||||
@ -62,7 +61,7 @@ Parts can have multiple defined parameters.
|
||||
|
||||
If a part is a *Template Part* then the *Variants* tab will be visible.
|
||||
|
||||
[Read about Part templates](./template.md)
|
||||
[Read about Part templates and variants](./template.md)
|
||||
|
||||
### Stock
|
||||
|
||||
|
@ -106,6 +106,7 @@ nav:
|
||||
- Part Views: part/views.md
|
||||
- Tracking: part/trackable.md
|
||||
- Parameters: part/parameter.md
|
||||
- Revisions: part/revision.md
|
||||
- Templates: part/template.md
|
||||
- Tests: part/test.md
|
||||
- Pricing: part/pricing.md
|
||||
|
@ -1,12 +1,16 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 219
|
||||
INVENTREE_API_VERSION = 220
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
v220 - 2024-07-11 : https://github.com/inventree/InvenTree/pull/7585
|
||||
- Adds "revision_of" field to Part serializer
|
||||
- Adds new API filters for "revision" status
|
||||
|
||||
v219 - 2024-07-11 : https://github.com/inventree/InvenTree/pull/7611
|
||||
- Adds new fields to the BuildItem API endpoints
|
||||
- Adds new ordering / filtering options to the BuildItem API endpoints
|
||||
|
@ -1,5 +1,7 @@
|
||||
"""JSON serializers for Build API."""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import transaction
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@ -209,7 +211,7 @@ class BuildOutputQuantitySerializer(BuildOutputSerializer):
|
||||
quantity = serializers.DecimalField(
|
||||
max_digits=15,
|
||||
decimal_places=5,
|
||||
min_value=0,
|
||||
min_value=Decimal(0),
|
||||
required=True,
|
||||
label=_('Quantity'),
|
||||
help_text=_('Enter quantity for build output'),
|
||||
@ -256,7 +258,7 @@ class BuildOutputCreateSerializer(serializers.Serializer):
|
||||
quantity = serializers.DecimalField(
|
||||
max_digits=15,
|
||||
decimal_places=5,
|
||||
min_value=0,
|
||||
min_value=Decimal(0),
|
||||
required=True,
|
||||
label=_('Quantity'),
|
||||
help_text=_('Enter quantity for build output'),
|
||||
@ -864,7 +866,7 @@ class BuildAllocationItemSerializer(serializers.Serializer):
|
||||
quantity = serializers.DecimalField(
|
||||
max_digits=15,
|
||||
decimal_places=5,
|
||||
min_value=0,
|
||||
min_value=Decimal(0),
|
||||
required=True
|
||||
)
|
||||
|
||||
|
@ -1408,6 +1408,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
|
||||
'validator': bool,
|
||||
'default': True,
|
||||
},
|
||||
'PART_REVISION_ASSEMBLY_ONLY': {
|
||||
'name': _('Assembly Revision Only'),
|
||||
'description': _('Only allow revisions for assembly parts'),
|
||||
'validator': bool,
|
||||
'default': False,
|
||||
},
|
||||
'PART_ALLOW_DELETE_FROM_ASSEMBLY': {
|
||||
'name': _('Allow Deletion from Assembly'),
|
||||
'description': _('Allow deletion of parts which are used in an assembly'),
|
||||
|
@ -57,22 +57,20 @@ class CompanyTest(InvenTreeAPITestCase):
|
||||
def test_company_detail(self):
|
||||
"""Tests for the Company detail endpoint."""
|
||||
url = reverse('api-company-detail', kwargs={'pk': self.acme.pk})
|
||||
response = self.get(url)
|
||||
response = self.get(url, expected_code=200)
|
||||
|
||||
self.assertIn('name', response.data.keys())
|
||||
self.assertEqual(response.data['name'], 'ACME')
|
||||
|
||||
# Change the name of the company
|
||||
# Note we should not have the correct permissions (yet)
|
||||
data = response.data
|
||||
response = self.client.patch(url, data, format='json', expected_code=400)
|
||||
|
||||
self.assignRole('company.change')
|
||||
|
||||
# Update the name and set the currency to a valid value
|
||||
data['name'] = 'ACMOO'
|
||||
data['currency'] = 'NZD'
|
||||
|
||||
response = self.client.patch(url, data, format='json', expected_code=200)
|
||||
response = self.patch(url, data, expected_code=200)
|
||||
|
||||
self.assertEqual(response.data['name'], 'ACMOO')
|
||||
self.assertEqual(response.data['currency'], 'NZD')
|
||||
|
@ -616,7 +616,7 @@ class PurchaseOrderLineItemReceiveSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
quantity = serializers.DecimalField(
|
||||
max_digits=15, decimal_places=5, min_value=0, required=True
|
||||
max_digits=15, decimal_places=5, min_value=Decimal(0), required=True
|
||||
)
|
||||
|
||||
def validate_quantity(self, quantity):
|
||||
@ -1250,7 +1250,7 @@ class SalesOrderShipmentAllocationItemSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
quantity = serializers.DecimalField(
|
||||
max_digits=15, decimal_places=5, min_value=0, required=True
|
||||
max_digits=15, decimal_places=5, min_value=Decimal(0), required=True
|
||||
)
|
||||
|
||||
def validate_quantity(self, quantity):
|
||||
|
@ -911,7 +911,27 @@ class PartFilter(rest_filters.FilterSet):
|
||||
"""Metaclass options for this filter set."""
|
||||
|
||||
model = Part
|
||||
fields = []
|
||||
fields = ['revision_of']
|
||||
|
||||
is_revision = rest_filters.BooleanFilter(
|
||||
label=_('Is Revision'), method='filter_is_revision'
|
||||
)
|
||||
|
||||
def filter_is_revision(self, queryset, name, value):
|
||||
"""Filter by whether the Part is a revision or not."""
|
||||
if str2bool(value):
|
||||
return queryset.exclude(revision_of=None)
|
||||
return queryset.filter(revision_of=None)
|
||||
|
||||
has_revisions = rest_filters.BooleanFilter(
|
||||
label=_('Has Revisions'), method='filter_has_revisions'
|
||||
)
|
||||
|
||||
def filter_has_revisions(self, queryset, name, value):
|
||||
"""Filter by whether the Part has any revisions or not."""
|
||||
if str2bool(value):
|
||||
return queryset.exclude(revision_count=0)
|
||||
return queryset.filter(revision_count=0)
|
||||
|
||||
has_units = rest_filters.BooleanFilter(label='Has units', method='filter_has_units')
|
||||
|
||||
@ -1361,6 +1381,8 @@ class PartList(PartMixin, DataExportViewMixin, ListCreateAPI):
|
||||
'pricing_min',
|
||||
'pricing_max',
|
||||
'pricing_updated',
|
||||
'revision',
|
||||
'revision_count',
|
||||
]
|
||||
|
||||
ordering_field_aliases = {
|
||||
|
@ -0,0 +1,19 @@
|
||||
# Generated by Django 4.2.12 on 2024-07-07 04:42
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('part', '0125_part_locked'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='part',
|
||||
name='revision_of',
|
||||
field=models.ForeignKey(help_text='Is this part a revision of another part?', null=True, blank=True, on_delete=django.db.models.deletion.SET_NULL, related_name='revisions', to='part.part', verbose_name='Revision Of'),
|
||||
),
|
||||
]
|
@ -662,6 +662,49 @@ class Part(
|
||||
if match is None:
|
||||
raise ValidationError(_(f'IPN must match regex pattern {pattern}'))
|
||||
|
||||
def validate_revision(self):
|
||||
"""Check the 'revision' and 'revision_of' fields."""
|
||||
# Part cannot be a revision of itself
|
||||
if self.revision_of:
|
||||
if self.revision_of == self:
|
||||
raise ValidationError({
|
||||
'revision_of': _('Part cannot be a revision of itself')
|
||||
})
|
||||
|
||||
# Part cannot be a revision of a part which is itself a revision
|
||||
if self.revision_of.revision_of:
|
||||
raise ValidationError({
|
||||
'revision_of': _(
|
||||
'Cannot make a revision of a part which is already a revision'
|
||||
)
|
||||
})
|
||||
|
||||
# If this part is a revision, it must have a revision code
|
||||
if not self.revision:
|
||||
raise ValidationError({
|
||||
'revision': _('Revision code must be specified')
|
||||
})
|
||||
|
||||
if get_global_setting('PART_REVISION_ASSEMBLY_ONLY'):
|
||||
if not self.assembly or not self.revision_of.assembly:
|
||||
raise ValidationError({
|
||||
'revision_of': _(
|
||||
'Revisions are only allowed for assembly parts'
|
||||
)
|
||||
})
|
||||
|
||||
# Cannot have a revision of a "template" part
|
||||
if self.revision_of.is_template:
|
||||
raise ValidationError({
|
||||
'revision_of': _('Cannot make a revision of a template part')
|
||||
})
|
||||
|
||||
# parent part must point to the same template (via variant_of)
|
||||
if self.variant_of != self.revision_of.variant_of:
|
||||
raise ValidationError({
|
||||
'revision_of': _('Parent part must point to the same template')
|
||||
})
|
||||
|
||||
def validate_serial_number(
|
||||
self,
|
||||
serial: str,
|
||||
@ -842,15 +885,24 @@ class Part(
|
||||
'IPN': _('Duplicate IPN not allowed in part settings')
|
||||
})
|
||||
|
||||
if self.revision_of and self.revision:
|
||||
if (
|
||||
Part.objects.exclude(pk=self.pk)
|
||||
.filter(revision_of=self.revision_of, revision=self.revision)
|
||||
.exists()
|
||||
):
|
||||
raise ValidationError(_('Duplicate part revision already exists.'))
|
||||
|
||||
# Ensure unique across (Name, revision, IPN) (as specified)
|
||||
if (
|
||||
Part.objects.exclude(pk=self.pk)
|
||||
.filter(name=self.name, revision=self.revision, IPN=self.IPN)
|
||||
.exists()
|
||||
):
|
||||
raise ValidationError(
|
||||
_('Part with this Name, IPN and Revision already exists.')
|
||||
)
|
||||
if self.revision or self.IPN:
|
||||
if (
|
||||
Part.objects.exclude(pk=self.pk)
|
||||
.filter(name=self.name, revision=self.revision, IPN=self.IPN)
|
||||
.exists()
|
||||
):
|
||||
raise ValidationError(
|
||||
_('Part with this Name, IPN and Revision already exists.')
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
"""Perform cleaning operations for the Part model.
|
||||
@ -867,6 +919,9 @@ class Part(
|
||||
'category': _('Parts cannot be assigned to structural part categories!')
|
||||
})
|
||||
|
||||
# Check the 'revision' and 'revision_of' fields
|
||||
self.validate_revision()
|
||||
|
||||
super().clean()
|
||||
|
||||
# Strip IPN field
|
||||
@ -954,6 +1009,16 @@ class Part(
|
||||
verbose_name=_('Revision'),
|
||||
)
|
||||
|
||||
revision_of = models.ForeignKey(
|
||||
'part.Part',
|
||||
related_name='revisions',
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
help_text=_('Is this part a revision of another part?'),
|
||||
verbose_name=_('Revision Of'),
|
||||
)
|
||||
|
||||
link = InvenTreeURLField(
|
||||
blank=True,
|
||||
null=True,
|
||||
|
@ -658,6 +658,8 @@ class PartSerializer(
|
||||
'pk',
|
||||
'purchaseable',
|
||||
'revision',
|
||||
'revision_of',
|
||||
'revision_count',
|
||||
'salable',
|
||||
'starred',
|
||||
'thumbnail',
|
||||
@ -762,6 +764,9 @@ class PartSerializer(
|
||||
"""
|
||||
queryset = queryset.prefetch_related('category', 'default_location')
|
||||
|
||||
# Annotate with the total number of revisions
|
||||
queryset = queryset.annotate(revision_count=SubqueryCount('revisions'))
|
||||
|
||||
# Annotate with the total number of stock items
|
||||
queryset = queryset.annotate(stock_item_count=SubqueryCount('stock_items'))
|
||||
|
||||
@ -883,6 +888,7 @@ class PartSerializer(
|
||||
required_for_build_orders = serializers.IntegerField(read_only=True)
|
||||
required_for_sales_orders = serializers.IntegerField(read_only=True)
|
||||
stock_item_count = serializers.IntegerField(read_only=True, label=_('Stock Items'))
|
||||
revision_count = serializers.IntegerField(read_only=True, label=_('Revisions'))
|
||||
suppliers = serializers.IntegerField(read_only=True, label=_('Suppliers'))
|
||||
total_in_stock = serializers.FloatField(read_only=True, label=_('Total Stock'))
|
||||
external_stock = serializers.FloatField(read_only=True, label=_('External Stock'))
|
||||
|
@ -271,6 +271,15 @@
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% settings_value "PART_ENABLE_REVISION" as show_revision %}
|
||||
{% if show_revision and part.revision_of %}
|
||||
<tr>
|
||||
<td><span class='fas fa-sitemap'></span></td>
|
||||
<td>{% trans "Revision Of" %}</td>
|
||||
<td>
|
||||
<a href='{% url "part-detail" part.revision_of.pk %}'>{{ part.revision_of.full_name }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if show_revision and part.revision %}
|
||||
<tr>
|
||||
<td><span class='fas fa-code-branch'></span></td>
|
||||
|
@ -389,6 +389,83 @@ class PartTest(TestCase):
|
||||
|
||||
part.delete()
|
||||
|
||||
def test_revisions(self):
|
||||
"""Test the 'revision' and 'revision_of' field."""
|
||||
template = Part.objects.create(
|
||||
name='Template part', description='A template part', is_template=True
|
||||
)
|
||||
|
||||
# Create a new part
|
||||
part = Part.objects.create(
|
||||
name='Master Part',
|
||||
description='Master part (will have revisions)',
|
||||
variant_of=template,
|
||||
)
|
||||
|
||||
self.assertEqual(part.revisions.count(), 0)
|
||||
|
||||
# Try to set as revision of itself
|
||||
with self.assertRaises(ValidationError) as exc:
|
||||
part.revision_of = part
|
||||
part.save()
|
||||
|
||||
self.assertIn('Part cannot be a revision of itself', str(exc.exception))
|
||||
|
||||
part.refresh_from_db()
|
||||
|
||||
rev_a = Part.objects.create(
|
||||
name='Master Part', description='Master part (revision A)'
|
||||
)
|
||||
|
||||
with self.assertRaises(ValidationError) as exc:
|
||||
print('rev a:', rev_a.revision_of, part.revision_of)
|
||||
rev_a.revision_of = part
|
||||
rev_a.save()
|
||||
|
||||
self.assertIn('Revision code must be specified', str(exc.exception))
|
||||
|
||||
with self.assertRaises(ValidationError) as exc:
|
||||
rev_a.revision_of = template
|
||||
rev_a.revision = 'A'
|
||||
rev_a.save()
|
||||
|
||||
self.assertIn('Cannot make a revision of a template part', str(exc.exception))
|
||||
|
||||
with self.assertRaises(ValidationError) as exc:
|
||||
rev_a.revision_of = part
|
||||
rev_a.revision = 'A'
|
||||
rev_a.save()
|
||||
|
||||
self.assertIn('Parent part must point to the same template', str(exc.exception))
|
||||
|
||||
rev_a.variant_of = template
|
||||
rev_a.revision_of = part
|
||||
rev_a.revision = 'A'
|
||||
rev_a.save()
|
||||
|
||||
self.assertEqual(part.revisions.count(), 1)
|
||||
|
||||
rev_b = Part.objects.create(
|
||||
name='Master Part', description='Master part (revision B)'
|
||||
)
|
||||
|
||||
with self.assertRaises(ValidationError) as exc:
|
||||
rev_b.revision_of = rev_a
|
||||
rev_b.revision = 'B'
|
||||
rev_b.save()
|
||||
|
||||
self.assertIn(
|
||||
'Cannot make a revision of a part which is already a revision',
|
||||
str(exc.exception),
|
||||
)
|
||||
|
||||
rev_b.variant_of = template
|
||||
rev_b.revision_of = part
|
||||
rev_b.revision = 'B'
|
||||
rev_b.save()
|
||||
|
||||
self.assertEqual(part.revisions.count(), 2)
|
||||
|
||||
|
||||
class TestTemplateTest(TestCase):
|
||||
"""Unit test for the TestTemplate class."""
|
||||
|
@ -1518,7 +1518,7 @@ class StockAdjustmentItemSerializer(serializers.Serializer):
|
||||
)
|
||||
|
||||
quantity = serializers.DecimalField(
|
||||
max_digits=15, decimal_places=5, min_value=0, required=True
|
||||
max_digits=15, decimal_places=5, min_value=Decimal(0), required=True
|
||||
)
|
||||
|
||||
batch = serializers.CharField(
|
||||
|
@ -12,6 +12,7 @@
|
||||
<table class='table table-striped table-condensed'>
|
||||
<tbody>
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_ENABLE_REVISION" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_REVISION_ASSEMBLY_ONLY" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_IPN_REGEX" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_DUPLICATE_IPN" %}
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_EDIT_IPN" %}
|
||||
|
@ -135,6 +135,7 @@ function partFields(options={}) {
|
||||
},
|
||||
name: {},
|
||||
IPN: {},
|
||||
revision_of: {},
|
||||
revision: {
|
||||
icon: 'fa-code-branch',
|
||||
},
|
||||
@ -227,6 +228,7 @@ function partFields(options={}) {
|
||||
// Pop 'revision' field
|
||||
if (!global_settings.PART_ENABLE_REVISION) {
|
||||
delete fields['revision'];
|
||||
delete fields['revision_of'];
|
||||
}
|
||||
|
||||
if (options.create || options.duplicate) {
|
||||
|
@ -29,4 +29,10 @@ export function setApiDefaults() {
|
||||
}
|
||||
}
|
||||
|
||||
export const queryClient = new QueryClient();
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -30,17 +30,6 @@ import { StylishText } from '../items/StylishText';
|
||||
import { getModelInfo } from '../render/ModelType';
|
||||
import { StatusRenderer } from '../render/StatusRenderer';
|
||||
|
||||
export type PartIconsType = {
|
||||
assembly: boolean;
|
||||
template: boolean;
|
||||
component: boolean;
|
||||
trackable: boolean;
|
||||
purchaseable: boolean;
|
||||
saleable: boolean;
|
||||
virtual: boolean;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
export type DetailsField =
|
||||
| {
|
||||
hidden?: boolean;
|
||||
|
@ -1,92 +0,0 @@
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { Badge, Tooltip } from '@mantine/core';
|
||||
|
||||
import { InvenTreeIcon, InvenTreeIconType } from '../../functions/icons';
|
||||
|
||||
/**
|
||||
* Fetches and wraps an InvenTreeIcon in a flex div
|
||||
* @param icon name of icon
|
||||
*
|
||||
*/
|
||||
function PartIcon(icon: InvenTreeIconType) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<InvenTreeIcon icon={icon} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a table cell with Part icons.
|
||||
* Only used for Part Model Details
|
||||
*/
|
||||
export function PartIcons({ part }: { part: any }) {
|
||||
return (
|
||||
<td colSpan={2}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
{part.locked && (
|
||||
<Tooltip label={t`Part is locked`}>
|
||||
<Badge color="black" variant="filled">
|
||||
<Trans>Locked</Trans>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!part.active && (
|
||||
<Tooltip label={t`Part is not active`}>
|
||||
<Badge color="red" variant="filled">
|
||||
<Trans>Inactive</Trans>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{part.template && (
|
||||
<Tooltip
|
||||
label={t`Part is a template part (variants can be made from this part)`}
|
||||
children={PartIcon('template')}
|
||||
/>
|
||||
)}
|
||||
{part.assembly && (
|
||||
<Tooltip
|
||||
label={t`Part can be assembled from other parts`}
|
||||
children={PartIcon('assembly')}
|
||||
/>
|
||||
)}
|
||||
{part.component && (
|
||||
<Tooltip
|
||||
label={t`Part can be used in assemblies`}
|
||||
children={PartIcon('component')}
|
||||
/>
|
||||
)}
|
||||
{part.trackable && (
|
||||
<Tooltip
|
||||
label={t`Part stock is tracked by serial number`}
|
||||
children={PartIcon('trackable')}
|
||||
/>
|
||||
)}
|
||||
{part.purchaseable && (
|
||||
<Tooltip
|
||||
label={t`Part can be purchased from external suppliers`}
|
||||
children={PartIcon('purchaseable')}
|
||||
/>
|
||||
)}
|
||||
{part.saleable && (
|
||||
<Tooltip
|
||||
label={t`Part can be sold to customers`}
|
||||
children={PartIcon('saleable')}
|
||||
/>
|
||||
)}
|
||||
{part.virtual && (
|
||||
<Tooltip label={t`Part is virtual (not a physical part)`}>
|
||||
<Badge color="yellow" variant="filled">
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '5px' }}
|
||||
>
|
||||
<InvenTreeIcon icon="virtual" iconProps={{ size: 18 }} />{' '}
|
||||
<Trans>Virtual</Trans>
|
||||
</div>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
@ -125,7 +125,6 @@ export function OptionsApiForm({
|
||||
const optionsQuery = useQuery({
|
||||
enabled: true,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
queryKey: [
|
||||
'form-options-data',
|
||||
id,
|
||||
|
@ -39,12 +39,14 @@ export function ActionDropdown({
|
||||
icon,
|
||||
tooltip,
|
||||
actions,
|
||||
disabled = false
|
||||
disabled = false,
|
||||
hidden = false
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
tooltip: string;
|
||||
actions: ActionDropdownItem[];
|
||||
disabled?: boolean;
|
||||
hidden?: boolean;
|
||||
}) {
|
||||
const hasActions = useMemo(() => {
|
||||
return actions.some((action) => !action.hidden);
|
||||
@ -58,7 +60,7 @@ export function ActionDropdown({
|
||||
return identifierString(`action-menu-${tooltip}`);
|
||||
}, [tooltip]);
|
||||
|
||||
return hasActions ? (
|
||||
return !hidden && hasActions ? (
|
||||
<Menu position="bottom-end" key={menuName}>
|
||||
<Indicator disabled={!indicatorProps} {...indicatorProps?.indicator}>
|
||||
<Menu.Target>
|
||||
|
@ -70,8 +70,7 @@ export function Header() {
|
||||
}
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: false
|
||||
refetchOnMount: true
|
||||
});
|
||||
|
||||
// Sync Navigation Drawer state with zustand
|
||||
|
@ -53,8 +53,7 @@ export function NotificationDrawer({
|
||||
.catch((error) => {
|
||||
return error;
|
||||
}),
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false
|
||||
refetchOnMount: false
|
||||
});
|
||||
|
||||
const hasNotifications: boolean = useMemo(() => {
|
||||
|
@ -275,8 +275,7 @@ export function SearchDrawer({
|
||||
// Search query manager
|
||||
const searchQuery = useQuery({
|
||||
queryKey: ['search', searchText, searchRegex, searchWhole],
|
||||
queryFn: performSearch,
|
||||
refetchOnWindowFocus: false
|
||||
queryFn: performSearch
|
||||
});
|
||||
|
||||
// A list of queries which return valid results
|
||||
|
@ -47,6 +47,7 @@ export interface InstanceRenderInterface {
|
||||
instance: any;
|
||||
link?: boolean;
|
||||
navigate?: any;
|
||||
showSecondary?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -149,10 +150,12 @@ export function RenderInlineModel({
|
||||
image,
|
||||
labels,
|
||||
url,
|
||||
navigate
|
||||
navigate,
|
||||
showSecondary = true
|
||||
}: {
|
||||
primary: string;
|
||||
secondary?: string;
|
||||
showSecondary?: boolean;
|
||||
suffix?: ReactNode;
|
||||
image?: string;
|
||||
labels?: string[];
|
||||
@ -181,7 +184,7 @@ export function RenderInlineModel({
|
||||
) : (
|
||||
<Text size="sm">{primary}</Text>
|
||||
)}
|
||||
{secondary && <Text size="xs">{secondary}</Text>}
|
||||
{showSecondary && secondary && <Text size="xs">{secondary}</Text>}
|
||||
</Group>
|
||||
{suffix && (
|
||||
<>
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Badge } from '@mantine/core';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
@ -12,14 +13,35 @@ export function RenderPart(
|
||||
props: Readonly<InstanceRenderInterface>
|
||||
): ReactNode {
|
||||
const { instance } = props;
|
||||
const stock = t`Stock` + `: ${instance.in_stock}`;
|
||||
|
||||
let badgeText = '';
|
||||
let badgeColor = 'green';
|
||||
|
||||
let stock = instance.total_in_stock;
|
||||
|
||||
if (instance.active == false) {
|
||||
badgeColor = 'red';
|
||||
badgeText = t`Inactive`;
|
||||
} else if (stock <= 0) {
|
||||
badgeColor = 'orange';
|
||||
badgeText = t`No stock`;
|
||||
} else {
|
||||
badgeText = t`Stock` + `: ${stock}`;
|
||||
badgeColor = instance.minimum_stock > stock ? 'yellow' : 'green';
|
||||
}
|
||||
|
||||
const badge = (
|
||||
<Badge size="xs" color={badgeColor}>
|
||||
{badgeText}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
return (
|
||||
<RenderInlineModel
|
||||
{...props}
|
||||
primary={instance.name}
|
||||
primary={instance.full_name ?? instance.name}
|
||||
secondary={instance.description}
|
||||
suffix={stock}
|
||||
suffix={badge}
|
||||
image={instance.thumnbnail || instance.image}
|
||||
url={props.link ? getDetailUrl(ModelType.part, instance.pk) : undefined}
|
||||
/>
|
||||
|
@ -69,6 +69,7 @@ export enum ApiEndpoints {
|
||||
|
||||
bom_list = 'bom/',
|
||||
bom_item_validate = 'bom/:id/validate/',
|
||||
bom_validate = 'part/:id/bom-validate/',
|
||||
|
||||
// Part API endpoints
|
||||
part_list = 'part/',
|
||||
|
@ -3,6 +3,7 @@ import { IconPackages } from '@tabler/icons-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
|
||||
import { useGlobalSettingsState } from '../states/SettingsState';
|
||||
|
||||
/**
|
||||
* Construct a set of fields for creating / editing a Part instance
|
||||
@ -21,9 +22,19 @@ export function usePartFields({
|
||||
},
|
||||
name: {},
|
||||
IPN: {},
|
||||
revision: {},
|
||||
description: {},
|
||||
variant_of: {},
|
||||
revision: {},
|
||||
revision_of: {
|
||||
filters: {
|
||||
is_revision: false,
|
||||
is_template: false
|
||||
}
|
||||
},
|
||||
variant_of: {
|
||||
filters: {
|
||||
is_template: true
|
||||
}
|
||||
},
|
||||
keywords: {},
|
||||
units: {},
|
||||
link: {},
|
||||
@ -82,13 +93,22 @@ export function usePartFields({
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: pop 'expiry' field if expiry not enabled
|
||||
delete fields['default_expiry'];
|
||||
const settings = useGlobalSettingsState.getState();
|
||||
|
||||
// TODO: pop 'revision' field if PART_ENABLE_REVISION is False
|
||||
delete fields['revision'];
|
||||
if (settings.isSet('PART_REVISION_ASSEMBLY_ONLY')) {
|
||||
fields.revision_of.filters['assembly'] = true;
|
||||
}
|
||||
|
||||
// TODO: handle part duplications
|
||||
// Pop 'revision' field if PART_ENABLE_REVISION is False
|
||||
if (!settings.isSet('PART_ENABLE_REVISION')) {
|
||||
delete fields['revision'];
|
||||
delete fields['revision_of'];
|
||||
}
|
||||
|
||||
// Pop 'expiry' field if expiry not enabled
|
||||
if (!settings.isSet('STOCK_ENABLE_EXPIRY')) {
|
||||
delete fields['default_expiry'];
|
||||
}
|
||||
|
||||
return fields;
|
||||
}, [create]);
|
||||
|
@ -33,6 +33,7 @@ import {
|
||||
IconGitBranch,
|
||||
IconGridDots,
|
||||
IconHash,
|
||||
IconHierarchy,
|
||||
IconInfoCircle,
|
||||
IconLayersLinked,
|
||||
IconLink,
|
||||
@ -89,7 +90,8 @@ import React from 'react';
|
||||
const icons = {
|
||||
name: IconPoint,
|
||||
description: IconInfoCircle,
|
||||
variant_of: IconStatusChange,
|
||||
variant_of: IconHierarchy,
|
||||
revision_of: IconStatusChange,
|
||||
unallocated_stock: IconPackage,
|
||||
total_in_stock: IconPackages,
|
||||
minimum_stock: IconFlag,
|
||||
|
@ -85,7 +85,7 @@ export function useInstance<T = any>({
|
||||
});
|
||||
},
|
||||
refetchOnMount: refetchOnMount,
|
||||
refetchOnWindowFocus: refetchOnWindowFocus,
|
||||
refetchOnWindowFocus: refetchOnWindowFocus ?? false,
|
||||
refetchInterval: updateInterval
|
||||
});
|
||||
|
||||
|
@ -180,11 +180,12 @@ export default function SystemSettings() {
|
||||
content: (
|
||||
<GlobalSettingList
|
||||
keys={[
|
||||
'PART_ENABLE_REVISION',
|
||||
'PART_IPN_REGEX',
|
||||
'PART_ALLOW_DUPLICATE_IPN',
|
||||
'PART_ALLOW_EDIT_IPN',
|
||||
'PART_ALLOW_DELETE_FROM_ASSEMBLY',
|
||||
'PART_ENABLE_REVISION',
|
||||
'PART_REVISION_ASSEMBLY_ONLY',
|
||||
'PART_NAME_FORMAT',
|
||||
'PART_SHOW_RELATED',
|
||||
'PART_CREATE_INITIAL',
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Alert, Grid, Skeleton, Stack, Table } from '@mantine/core';
|
||||
import { Alert, Grid, Skeleton, Space, Stack, Text } from '@mantine/core';
|
||||
import {
|
||||
IconBookmarks,
|
||||
IconBuilding,
|
||||
@ -22,9 +22,10 @@ import {
|
||||
IconTruckDelivery,
|
||||
IconVersions
|
||||
} from '@tabler/icons-react';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { ReactNode, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import Select from 'react-select';
|
||||
|
||||
import { api } from '../../App';
|
||||
import AdminButton from '../../components/buttons/AdminButton';
|
||||
@ -32,7 +33,6 @@ import { DetailsField, DetailsTable } from '../../components/details/Details';
|
||||
import DetailsBadge from '../../components/details/DetailsBadge';
|
||||
import { DetailsImage } from '../../components/details/DetailsImage';
|
||||
import { ItemDetailsGrid } from '../../components/details/ItemDetails';
|
||||
import { PartIcons } from '../../components/details/PartIcons';
|
||||
import NotesEditor from '../../components/editors/NotesEditor';
|
||||
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
|
||||
import { Thumbnail } from '../../components/images/Thumbnail';
|
||||
@ -51,6 +51,7 @@ import InstanceDetail from '../../components/nav/InstanceDetail';
|
||||
import NavigationTree from '../../components/nav/NavigationTree';
|
||||
import { PageDetail } from '../../components/nav/PageDetail';
|
||||
import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
|
||||
import { RenderPart } from '../../components/render/Part';
|
||||
import { formatPriceRange } from '../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
@ -70,6 +71,7 @@ import {
|
||||
} from '../../hooks/UseForm';
|
||||
import { useInstance } from '../../hooks/UseInstance';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { BomTable } from '../../tables/bom/BomTable';
|
||||
import { UsedInTable } from '../../tables/bom/UsedInTable';
|
||||
@ -96,6 +98,8 @@ export default function PartDetail() {
|
||||
|
||||
const [treeOpen, setTreeOpen] = useState(false);
|
||||
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
|
||||
const {
|
||||
instance: part,
|
||||
refreshInstance,
|
||||
@ -137,6 +141,20 @@ export default function PartDetail() {
|
||||
model: ModelType.part,
|
||||
hidden: !part.variant_of
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
name: 'revision_of',
|
||||
label: t`Revision of`,
|
||||
model: ModelType.part,
|
||||
hidden: !part.revision_of
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'revision',
|
||||
label: t`Revision`,
|
||||
hidden: !part.revision,
|
||||
copy: true
|
||||
},
|
||||
{
|
||||
type: 'link',
|
||||
name: 'category',
|
||||
@ -164,13 +182,6 @@ export default function PartDetail() {
|
||||
copy: true,
|
||||
hidden: !part.IPN
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'revision',
|
||||
label: t`Revision`,
|
||||
copy: true,
|
||||
hidden: !part.revision
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'units',
|
||||
@ -449,7 +460,7 @@ export default function PartDetail() {
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
return part ? (
|
||||
<ItemDetailsGrid>
|
||||
<Grid>
|
||||
<Grid.Col span={4}>
|
||||
@ -467,22 +478,15 @@ export default function PartDetail() {
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={8}>
|
||||
<Stack gap="xs">
|
||||
<Table>
|
||||
<Table.Tbody>
|
||||
<Table.Tr>
|
||||
<PartIcons part={part} />
|
||||
</Table.Tr>
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<DetailsTable fields={tl} item={part} />
|
||||
</Stack>
|
||||
<DetailsTable fields={tl} item={part} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<DetailsTable fields={tr} item={part} />
|
||||
<DetailsTable fields={bl} item={part} />
|
||||
<DetailsTable fields={br} item={part} />
|
||||
</ItemDetailsGrid>
|
||||
) : (
|
||||
<Skeleton />
|
||||
);
|
||||
}, [part, instanceQuery]);
|
||||
|
||||
@ -655,6 +659,89 @@ export default function PartDetail() {
|
||||
];
|
||||
}, [id, part, user]);
|
||||
|
||||
// Fetch information on part revision
|
||||
const partRevisionQuery = useQuery({
|
||||
refetchOnMount: true,
|
||||
queryKey: [
|
||||
'part_revisions',
|
||||
part.pk,
|
||||
part.revision_of,
|
||||
part.revision_count
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!part.revision_of && !part.revision_count) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let revisions = [];
|
||||
|
||||
// First, fetch information for the top-level part
|
||||
if (part.revision_of) {
|
||||
await api
|
||||
.get(apiUrl(ApiEndpoints.part_list, part.revision_of))
|
||||
.then((response) => {
|
||||
revisions.push(response.data);
|
||||
});
|
||||
} else {
|
||||
revisions.push(part);
|
||||
}
|
||||
|
||||
const url = apiUrl(ApiEndpoints.part_list);
|
||||
|
||||
await api
|
||||
.get(url, {
|
||||
params: {
|
||||
revision_of: part.revision_of || part.pk
|
||||
}
|
||||
})
|
||||
.then((response) => {
|
||||
switch (response.status) {
|
||||
case 200:
|
||||
response.data.forEach((r: any) => {
|
||||
revisions.push(r);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return revisions;
|
||||
}
|
||||
});
|
||||
|
||||
const partRevisionOptions: any[] = useMemo(() => {
|
||||
if (partRevisionQuery.isFetching || !partRevisionQuery.data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!part.revision_of && !part.revision_count) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let options: any[] = partRevisionQuery.data.map((revision: any) => {
|
||||
return {
|
||||
value: revision.pk,
|
||||
label: revision.full_name,
|
||||
part: revision
|
||||
};
|
||||
});
|
||||
|
||||
// Add this part if not already available
|
||||
if (!options.find((o) => o.value == part.pk)) {
|
||||
options.push({
|
||||
value: part.pk,
|
||||
label: part.full_name,
|
||||
part: part
|
||||
});
|
||||
}
|
||||
|
||||
return options.sort((a, b) => {
|
||||
return ('' + a.part.revision).localeCompare(b.part.revision);
|
||||
});
|
||||
}, [part, partRevisionQuery.isFetching, partRevisionQuery.data]);
|
||||
|
||||
const breadcrumbs = useMemo(
|
||||
() => [
|
||||
{ name: t`Parts`, url: '/part' },
|
||||
@ -686,7 +773,7 @@ export default function PartDetail() {
|
||||
/>,
|
||||
<DetailsBadge
|
||||
label={t`No Stock`}
|
||||
color="red"
|
||||
color="orange"
|
||||
visible={part.total_in_stock == 0}
|
||||
key="no_stock"
|
||||
/>,
|
||||
@ -705,7 +792,7 @@ export default function PartDetail() {
|
||||
<DetailsBadge
|
||||
label={t`Locked`}
|
||||
color="black"
|
||||
visible={part.locked}
|
||||
visible={part.locked == true}
|
||||
key="locked"
|
||||
/>,
|
||||
<DetailsBadge
|
||||
@ -738,14 +825,22 @@ export default function PartDetail() {
|
||||
value: part.pk,
|
||||
hidden: true
|
||||
},
|
||||
copy_image: {},
|
||||
copy_bom: {},
|
||||
copy_notes: {},
|
||||
copy_parameters: {}
|
||||
copy_image: {
|
||||
value: true
|
||||
},
|
||||
copy_bom: {
|
||||
value: globalSettings.isSet('PART_COPY_BOM')
|
||||
},
|
||||
copy_notes: {
|
||||
value: true
|
||||
},
|
||||
copy_parameters: {
|
||||
value: globalSettings.isSet('PART_COPY_PARAMETERS')
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [createPartFields, part]);
|
||||
}, [createPartFields, globalSettings, part]);
|
||||
|
||||
const duplicatePart = useCreateApiFormModal({
|
||||
url: ApiEndpoints.part_list,
|
||||
@ -859,6 +954,13 @@ export default function PartDetail() {
|
||||
];
|
||||
}, [id, part, user]);
|
||||
|
||||
const enableRevisionSelection: boolean = useMemo(() => {
|
||||
return (
|
||||
partRevisionOptions.length > 0 &&
|
||||
globalSettings.isSet('PART_ENABLE_REVISION')
|
||||
);
|
||||
}, [partRevisionOptions, globalSettings]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{duplicatePart.modal}
|
||||
@ -886,6 +988,40 @@ export default function PartDetail() {
|
||||
setTreeOpen(true);
|
||||
}}
|
||||
actions={partActions}
|
||||
detail={
|
||||
enableRevisionSelection ? (
|
||||
<Stack gap="xs">
|
||||
<Text>{t`Select Part Revision`}</Text>
|
||||
<Select
|
||||
id="part-revision-select"
|
||||
aria-label="part-revision-select"
|
||||
options={partRevisionOptions}
|
||||
value={{
|
||||
value: part.pk,
|
||||
label: part.full_name,
|
||||
part: part
|
||||
}}
|
||||
isSearchable={false}
|
||||
formatOptionLabel={(option: any) =>
|
||||
RenderPart({
|
||||
instance: option.part,
|
||||
showSecondary: false
|
||||
})
|
||||
}
|
||||
onChange={(value: any) => {
|
||||
navigate(getDetailUrl(ModelType.part, value.value));
|
||||
}}
|
||||
styles={{
|
||||
menuPortal: (base: any) => ({ ...base, zIndex: 9999 }),
|
||||
menu: (base: any) => ({ ...base, zIndex: 9999 }),
|
||||
menuList: (base: any) => ({ ...base, zIndex: 9999 })
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
) : (
|
||||
<Space />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<PanelGroup pageKey="part" panels={partPanels} />
|
||||
{transferStockItems.modal}
|
||||
|
@ -178,7 +178,6 @@ export function InvenTreeTable<T = any>({
|
||||
queryKey: ['options', url, tableState.tableKey, props.enableColumnCaching],
|
||||
retry: 3,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: false,
|
||||
queryFn: async () => {
|
||||
if (props.enableColumnCaching == false) {
|
||||
return null;
|
||||
@ -483,7 +482,6 @@ export function InvenTreeTable<T = any>({
|
||||
tableState.searchTerm
|
||||
],
|
||||
queryFn: fetchTableData,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: true
|
||||
});
|
||||
|
||||
|
@ -11,6 +11,7 @@ import { ReactNode, useCallback, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { api } from '../../App';
|
||||
import { ActionButton } from '../../components/buttons/ActionButton';
|
||||
import { AddItemButton } from '../../components/buttons/AddItemButton';
|
||||
import { YesNoButton } from '../../components/buttons/YesNoButton';
|
||||
import { Thumbnail } from '../../components/images/Thumbnail';
|
||||
@ -20,6 +21,7 @@ import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
import { bomItemFields } from '../../forms/BomForms';
|
||||
import {
|
||||
useApiFormModal,
|
||||
useCreateApiFormModal,
|
||||
useDeleteApiFormModal,
|
||||
useEditApiFormModal
|
||||
@ -371,6 +373,26 @@ export function BomTable({
|
||||
table: table
|
||||
});
|
||||
|
||||
const validateBom = useApiFormModal({
|
||||
url: ApiEndpoints.bom_validate,
|
||||
method: 'PUT',
|
||||
fields: {
|
||||
valid: {
|
||||
hidden: true,
|
||||
value: true
|
||||
}
|
||||
},
|
||||
title: t`Validate BOM`,
|
||||
pk: partId,
|
||||
preFormContent: (
|
||||
<Alert color="green" icon={<IconCircleCheck />} title={t`Validate BOM`}>
|
||||
<Text>{t`Do you want to validate the bill of materials for this assembly?`}</Text>
|
||||
</Alert>
|
||||
),
|
||||
successMessage: t`BOM validated`,
|
||||
onFormSuccess: () => table.refreshTable()
|
||||
});
|
||||
|
||||
const validateBomItem = useCallback((record: any) => {
|
||||
const url = apiUrl(ApiEndpoints.bom_item_validate, record.pk);
|
||||
|
||||
@ -445,6 +467,12 @@ export function BomTable({
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
return [
|
||||
<ActionButton
|
||||
hidden={partLocked || !user.hasChangeRole(UserRoles.part)}
|
||||
tooltip={t`Validate BOM`}
|
||||
icon={<IconCircleCheck />}
|
||||
onClick={() => validateBom.open()}
|
||||
/>,
|
||||
<AddItemButton
|
||||
hidden={partLocked || !user.hasAddRole(UserRoles.part)}
|
||||
tooltip={t`Add BOM Item`}
|
||||
@ -457,12 +485,13 @@ export function BomTable({
|
||||
<>
|
||||
{newBomItem.modal}
|
||||
{editBomItem.modal}
|
||||
{validateBom.modal}
|
||||
{deleteBomItem.modal}
|
||||
<Stack gap="xs">
|
||||
{partLocked && (
|
||||
<Alert
|
||||
title={t`Part is Locked`}
|
||||
color="red"
|
||||
color="orange"
|
||||
icon={<IconLock />}
|
||||
p="xs"
|
||||
>
|
||||
|
@ -189,7 +189,7 @@ export function PartParameterTable({
|
||||
{partLocked && (
|
||||
<Alert
|
||||
title={t`Part is Locked`}
|
||||
color="red"
|
||||
color="orange"
|
||||
icon={<IconLock />}
|
||||
p="xs"
|
||||
>
|
||||
|
@ -34,6 +34,10 @@ function partTableColumns(): TableColumn[] {
|
||||
accessor: 'IPN',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
accessor: 'revision',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
accessor: 'units',
|
||||
sortable: true
|
||||
@ -257,6 +261,16 @@ function partTableFilters(): TableFilter[] {
|
||||
description: t`Filter by parts which are templates`,
|
||||
type: 'boolean'
|
||||
},
|
||||
{
|
||||
name: 'is_revision',
|
||||
label: t`Is Revision`,
|
||||
description: t`Filter by parts which are revisions`
|
||||
},
|
||||
{
|
||||
name: 'has_revisions',
|
||||
label: t`Has Revisions`,
|
||||
description: t`Filter by parts which have revisions`
|
||||
},
|
||||
{
|
||||
name: 'has_pricing',
|
||||
label: t`Has Pricing`,
|
||||
|
@ -67,8 +67,9 @@ export const test = baseTest.extend({
|
||||
url != 'http://localhost:8000/api/user/me/' &&
|
||||
url != 'http://localhost:8000/api/user/token/' &&
|
||||
url != 'http://localhost:8000/api/barcode/' &&
|
||||
url != 'http://localhost:8000/api/news/?search=&offset=0&limit=25' &&
|
||||
url != 'https://docs.inventree.org/en/versions.json' &&
|
||||
!url.startsWith('http://localhost:8000/api/news/') &&
|
||||
!url.startsWith('http://localhost:8000/api/notifications/') &&
|
||||
!url.startsWith('chrome://') &&
|
||||
url.indexOf('99999') < 0
|
||||
)
|
||||
|
@ -254,3 +254,21 @@ test('PUI - Pages - Part - 404', async ({ page }) => {
|
||||
// Clear out any console error messages
|
||||
await page.evaluate(() => console.clear());
|
||||
});
|
||||
|
||||
test('PUI - Pages - Part - Revision', async ({ page }) => {
|
||||
await doQuickLogin(page);
|
||||
|
||||
await page.goto(`${baseUrl}/part/906/details`);
|
||||
|
||||
await page.getByText('Revision of').waitFor();
|
||||
await page.getByText('Select Part Revision').waitFor();
|
||||
await page
|
||||
.getByText('Green Round Table (revision B) | B', { exact: true })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('option', { name: 'Thumbnail Green Round Table No stock' })
|
||||
.click();
|
||||
|
||||
await page.waitForURL('**/platform/part/101/**');
|
||||
await page.getByText('Select Part Revision').waitFor();
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user