mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-29 18:20:53 +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:
docs
src
backend
InvenTree
InvenTree
build
common
company
order
part
stock
templates
frontend
@ -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) {
|
||||
|
Reference in New Issue
Block a user