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:
Oliver 2024-07-12 14:37:32 +10:00 committed by GitHub
parent fb17078497
commit 767b76314e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 620 additions and 185 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

View 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.

View File

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

View File

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

View File

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

View File

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

View File

@ -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'),

View File

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

View File

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

View File

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

View File

@ -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'),
),
]

View File

@ -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,

View File

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

View File

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

View File

@ -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."""

View File

@ -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(

View File

@ -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" %}

View File

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

View File

@ -29,4 +29,10 @@ export function setApiDefaults() {
}
}
export const queryClient = new QueryClient();
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false
}
}
});

View File

@ -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;

View File

@ -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>
);
}

View File

@ -125,7 +125,6 @@ export function OptionsApiForm({
const optionsQuery = useQuery({
enabled: true,
refetchOnMount: false,
refetchOnWindowFocus: false,
queryKey: [
'form-options-data',
id,

View File

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

View File

@ -70,8 +70,7 @@ export function Header() {
}
},
refetchInterval: 30000,
refetchOnMount: true,
refetchOnWindowFocus: false
refetchOnMount: true
});
// Sync Navigation Drawer state with zustand

View File

@ -53,8 +53,7 @@ export function NotificationDrawer({
.catch((error) => {
return error;
}),
refetchOnMount: false,
refetchOnWindowFocus: false
refetchOnMount: false
});
const hasNotifications: boolean = useMemo(() => {

View File

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

View File

@ -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 && (
<>

View File

@ -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}
/>

View File

@ -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/',

View File

@ -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]);

View File

@ -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,

View File

@ -85,7 +85,7 @@ export function useInstance<T = any>({
});
},
refetchOnMount: refetchOnMount,
refetchOnWindowFocus: refetchOnWindowFocus,
refetchOnWindowFocus: refetchOnWindowFocus ?? false,
refetchInterval: updateInterval
});

View File

@ -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',

View File

@ -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}

View File

@ -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
});

View File

@ -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"
>

View File

@ -189,7 +189,7 @@ export function PartParameterTable({
{partLocked && (
<Alert
title={t`Part is Locked`}
color="red"
color="orange"
icon={<IconLock />}
p="xs"
>

View File

@ -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`,

View File

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

View File

@ -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();
});