2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-29 18:20:53 +00:00

Revision Improvements ()

* 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:
Oliver
2024-07-12 14:37:32 +10:00
committed by GitHub
parent fb17078497
commit 767b76314e
43 changed files with 620 additions and 185 deletions

@ -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) {