mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 13:15:43 +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:
		| @@ -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