diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 3ab80eb4bb..76907a626a 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,13 +1,16 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 282 +INVENTREE_API_VERSION = 283 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v283 - 2024-11-20 : https://github.com/inventree/InvenTree/pull/8524 + - Adds "note" field to the PartRelated API endpoint + v282 - 2024-11-19 : https://github.com/inventree/InvenTree/pull/8487 - Remove the "test statistics" API endpoints - This is now provided via a custom plugin diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index 2f8845c5ec..36663a7847 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -1427,37 +1427,50 @@ class PartDetail(PartMixin, RetrieveUpdateDestroyAPI): return response -class PartRelatedList(ListCreateAPI): - """API endpoint for accessing a list of PartRelated objects.""" +class PartRelatedFilter(rest_filters.FilterSet): + """FilterSet for PartRelated objects.""" + + class Meta: + """Metaclass options.""" + + model = PartRelated + fields = ['part_1', 'part_2'] + + part = rest_filters.ModelChoiceFilter( + queryset=Part.objects.all(), method='filter_part', label=_('Part') + ) + + def filter_part(self, queryset, name, part): + """Filter queryset to include only PartRelated objects which reference the specified part.""" + return queryset.filter(Q(part_1=part) | Q(part_2=part)).distinct() + + +class PartRelatedMixin: + """Mixin class for PartRelated API endpoints.""" queryset = PartRelated.objects.all() serializer_class = part_serializers.PartRelationSerializer - def filter_queryset(self, queryset): - """Custom queryset filtering.""" - queryset = super().filter_queryset(queryset) + def get_queryset(self, *args, **kwargs): + """Return an annotated queryset for the PartRelatedDetail endpoint.""" + queryset = super().get_queryset(*args, **kwargs) - params = self.request.query_params - - # Add a filter for "part" - we can filter either part_1 or part_2 - part = params.get('part', None) - - if part is not None: - try: - part = Part.objects.get(pk=part) - queryset = queryset.filter(Q(part_1=part) | Q(part_2=part)).distinct() - - except (ValueError, Part.DoesNotExist): - pass + queryset = queryset.prefetch_related('part_1', 'part_2') return queryset -class PartRelatedDetail(RetrieveUpdateDestroyAPI): - """API endpoint for accessing detail view of a PartRelated object.""" +class PartRelatedList(PartRelatedMixin, ListCreateAPI): + """API endpoint for accessing a list of PartRelated objects.""" - queryset = PartRelated.objects.all() - serializer_class = part_serializers.PartRelationSerializer + filterset_class = PartRelatedFilter + filter_backends = SEARCH_ORDER_FILTER + + search_fields = ['part_1__name', 'part_2__name'] + + +class PartRelatedDetail(PartRelatedMixin, RetrieveUpdateDestroyAPI): + """API endpoint for accessing detail view of a PartRelated object.""" class PartParameterTemplateFilter(rest_filters.FilterSet): diff --git a/src/backend/InvenTree/part/migrations/0131_partrelated_note.py b/src/backend/InvenTree/part/migrations/0131_partrelated_note.py new file mode 100644 index 0000000000..24d08cbcfb --- /dev/null +++ b/src/backend/InvenTree/part/migrations/0131_partrelated_note.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-11-19 12:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0130_alter_parttesttemplate_part'), + ] + + operations = [ + migrations.AddField( + model_name='partrelated', + name='note', + field=models.CharField(blank=True, help_text='Note for this relationship', max_length=500, verbose_name='Note'), + ), + ] diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index 20758957ff..6aab939864 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -4680,6 +4680,13 @@ class PartRelated(InvenTree.models.InvenTreeMetadataModel): help_text=_('Select Related Part'), ) + note = models.CharField( + max_length=500, + blank=True, + verbose_name=_('Note'), + help_text=_('Note for this relationship'), + ) + def __str__(self): """Return a string representation of this Part-Part relationship.""" return f'{self.part_1} <--> {self.part_2}' diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 5ac3d2c54e..85badda77d 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -1505,7 +1505,7 @@ class PartRelationSerializer(InvenTree.serializers.InvenTreeModelSerializer): """Metaclass defining serializer fields.""" model = PartRelated - fields = ['pk', 'part_1', 'part_1_detail', 'part_2', 'part_2_detail'] + fields = ['pk', 'part_1', 'part_1_detail', 'part_2', 'part_2_detail', 'note'] part_1_detail = PartSerializer(source='part_1', read_only=True, many=False) part_2_detail = PartSerializer(source='part_2', read_only=True, many=False) diff --git a/src/frontend/src/tables/part/RelatedPartTable.tsx b/src/frontend/src/tables/part/RelatedPartTable.tsx index d1e4f8bbd9..34eb531321 100644 --- a/src/frontend/src/tables/part/RelatedPartTable.tsx +++ b/src/frontend/src/tables/part/RelatedPartTable.tsx @@ -10,14 +10,15 @@ import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { UserRoles } from '../../enums/Roles'; import { useCreateApiFormModal, - useDeleteApiFormModal + useDeleteApiFormModal, + useEditApiFormModal } from '../../hooks/UseForm'; import { useTable } from '../../hooks/UseTable'; import { apiUrl } from '../../states/ApiState'; import { useUserState } from '../../states/UserState'; import type { TableColumn } from '../Column'; import { InvenTreeTable } from '../InvenTreeTable'; -import { type RowAction, RowDeleteAction } from '../RowActions'; +import { type RowAction, RowDeleteAction, RowEditAction } from '../RowActions'; /** * Construct a table listing related parts for a given part @@ -45,6 +46,7 @@ export function RelatedPartTable({ { accessor: 'part', title: t`Part`, + switchable: false, render: (record: any) => { const part = getPart(record); return ( @@ -63,11 +65,16 @@ export function RelatedPartTable({ }, { accessor: 'description', - title: t`Description`, + title: t`Part Description`, ellipsis: true, render: (record: any) => { return getPart(record).description; } + }, + { + accessor: 'note', + title: t`Note`, + sortable: false } ]; }, [partId]); @@ -102,6 +109,16 @@ export function RelatedPartTable({ table: table }); + const editRelatedPart = useEditApiFormModal({ + url: ApiEndpoints.related_part_list, + pk: selectedRelatedPart, + title: t`Edit Related Part`, + fields: { + note: {} + }, + table: table + }); + const tableActions: ReactNode[] = useMemo(() => { return [ { return [ + RowEditAction({ + hidden: !user.hasChangeRole(UserRoles.part), + onClick: () => { + setSelectedRelatedPart(record.pk); + editRelatedPart.open(); + } + }), RowDeleteAction({ hidden: !user.hasDeleteRole(UserRoles.part), onClick: () => { @@ -131,6 +155,7 @@ export function RelatedPartTable({ return ( <> {newRelatedPart.modal} + {editRelatedPart.modal} {deleteRelatedPart.modal}