Skip to content

Commit

Permalink
Revision Improvements (#7585)
Browse files Browse the repository at this point in the history
* 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](encode/django-rest-framework@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>
  • Loading branch information
3 people authored Jul 12, 2024
1 parent fb17078 commit 767b763
Show file tree
Hide file tree
Showing 43 changed files with 620 additions and 185 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/docs/assets/images/part/part_revision_b.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
78 changes: 78 additions & 0 deletions docs/docs/part/revision.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 2 additions & 3 deletions docs/docs/part/views.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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.

Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion src/backend/InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
8 changes: 5 additions & 3 deletions src/backend/InvenTree/build/serializers.py
Original file line number Diff line number Diff line change
@@ -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 _
Expand Down Expand Up @@ -209,7 +211,7 @@ class Meta:
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'),
Expand Down Expand Up @@ -256,7 +258,7 @@ class Meta:
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'),
Expand Down Expand Up @@ -864,7 +866,7 @@ def validate_stock_item(self, stock_item):
quantity = serializers.DecimalField(
max_digits=15,
decimal_places=5,
min_value=0,
min_value=Decimal(0),
required=True
)

Expand Down
6 changes: 6 additions & 0 deletions src/backend/InvenTree/common/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1408,6 +1408,12 @@ def save(self, *args, **kwargs):
'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'),
Expand Down
8 changes: 3 additions & 5 deletions src/backend/InvenTree/company/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,22 +57,20 @@ def test_company_list(self):
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')
Expand Down
4 changes: 2 additions & 2 deletions src/backend/InvenTree/order/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -616,7 +616,7 @@ def validate_line_item(self, item):
)

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):
Expand Down Expand Up @@ -1250,7 +1250,7 @@ def validate_line_item(self, line_item):
)

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):
Expand Down
24 changes: 23 additions & 1 deletion src/backend/InvenTree/part/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -911,7 +911,27 @@ class Meta:
"""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')

Expand Down Expand Up @@ -1361,6 +1381,8 @@ def filter_parametric_data(self, queryset):
'pricing_min',
'pricing_max',
'pricing_updated',
'revision',
'revision_count',
]

ordering_field_aliases = {
Expand Down
19 changes: 19 additions & 0 deletions src/backend/InvenTree/part/migrations/0126_part_revision_of.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
81 changes: 73 additions & 8 deletions src/backend/InvenTree/part/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,49 @@ def validate_ipn(self, raise_error=True):
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,
Expand Down Expand Up @@ -842,15 +885,24 @@ def validate_unique(self, exclude=None):
'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.
Expand All @@ -867,6 +919,9 @@ def clean(self):
'category': _('Parts cannot be assigned to structural part categories!')
})

# Check the 'revision' and 'revision_of' fields
self.validate_revision()

super().clean()

# Strip IPN field
Expand Down Expand Up @@ -954,6 +1009,16 @@ def ensure_trackable(self):
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,
Expand Down
Loading

0 comments on commit 767b763

Please sign in to comment.