diff --git a/docs/docs/assets/images/part/create_part_parameter.png b/docs/docs/assets/images/part/create_part_parameter.png index 4ed585fbfa..c36d0a4b0f 100644 Binary files a/docs/docs/assets/images/part/create_part_parameter.png and b/docs/docs/assets/images/part/create_part_parameter.png differ diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 503007bb2e..00a754e330 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,12 +1,15 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 368 +INVENTREE_API_VERSION = 369 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v369 -> 2025-07-15 : https://github.com/inventree/InvenTree/pull/10023 + - Adds "note", "updated", "updated_by" fields to the PartParameter API endpoints + v368 -> 2025-07-11 : https://github.com/inventree/InvenTree/pull/9673 - Adds 'tax_id' to company model - Adds 'tax_id' to search fields in the 'CompanyList' API endpoint diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 3a006fb962..1ece0f99a1 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -104,6 +104,42 @@ class MetaMixin(models.Model): ) +class UpdatedUserMixin(models.Model): + """A mixin which stores additional information about the user who created or last modified the object.""" + + class Meta: + """Meta options for MetaUserMixin.""" + + abstract = True + + def save(self, *args, **kwargs): + """Extract the user object from kwargs, if provided.""" + if updated_by := kwargs.pop('updated_by', None): + self.updated_by = updated_by + + self.updated = InvenTree.helpers.current_time() + + super().save(*args, **kwargs) + + updated = models.DateTimeField( + verbose_name=_('Updated'), + help_text=_('Timestamp of last update'), + default=None, + blank=True, + null=True, + ) + + updated_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='%(class)s_updated', + verbose_name=_('Update By'), + help_text=_('User who last updated this object'), + ) + + class ProjectCode(InvenTree.models.InvenTreeMetadataModel): """A ProjectCode is a unique identifier for a project.""" diff --git a/src/backend/InvenTree/importer/serializers.py b/src/backend/InvenTree/importer/serializers.py index 234cbf41b6..ad33d6e6a1 100644 --- a/src/backend/InvenTree/importer/serializers.py +++ b/src/backend/InvenTree/importer/serializers.py @@ -125,9 +125,7 @@ class DataImportSessionSerializer(InvenTreeModelSerializer): """ session = super().create(validated_data) - request = self.context.get('request', None) - - if request: + if request := self.context.get('request', None): session.user = request.user session.save() diff --git a/src/backend/InvenTree/part/admin.py b/src/backend/InvenTree/part/admin.py index 82a0f9ab13..3907eae294 100644 --- a/src/backend/InvenTree/part/admin.py +++ b/src/backend/InvenTree/part/admin.py @@ -121,6 +121,8 @@ class ParameterAdmin(admin.ModelAdmin): list_display = ('part', 'template', 'data') + readonly_fields = ('updated', 'updated_by') + autocomplete_fields = ('part', 'template') diff --git a/src/backend/InvenTree/part/api.py b/src/backend/InvenTree/part/api.py index 90ee0e5197..3a78858122 100644 --- a/src/backend/InvenTree/part/api.py +++ b/src/backend/InvenTree/part/api.py @@ -1392,9 +1392,16 @@ class PartParameterAPIMixin: def get_queryset(self, *args, **kwargs): """Override get_queryset method to prefetch related fields.""" queryset = super().get_queryset(*args, **kwargs) - queryset = queryset.prefetch_related('part', 'template') + queryset = queryset.prefetch_related('part', 'template', 'updated_by') return queryset + def get_serializer_context(self): + """Pass the 'request' object through to the serializer context.""" + context = super().get_serializer_context() + context['request'] = self.request + + return context + def get_serializer(self, *args, **kwargs): """Return the serializer instance for this API endpoint. @@ -1420,7 +1427,7 @@ class PartParameterFilter(rest_filters.FilterSet): """Metaclass options for the filterset.""" model = PartParameter - fields = ['template'] + fields = ['template', 'updated_by'] part = rest_filters.ModelChoiceFilter( queryset=Part.objects.all(), method='filter_part' @@ -1453,7 +1460,7 @@ class PartParameterList(PartParameterAPIMixin, DataExportViewMixin, ListCreateAP filter_backends = SEARCH_ORDER_FILTER_ALIAS - ordering_fields = ['name', 'data', 'part', 'template'] + ordering_fields = ['name', 'data', 'part', 'template', 'updated', 'updated_by'] ordering_field_aliases = { 'name': 'template__name', diff --git a/src/backend/InvenTree/part/migrations/0136_partparameter_note_partparameter_updated_and_more.py b/src/backend/InvenTree/part/migrations/0136_partparameter_note_partparameter_updated_and_more.py new file mode 100644 index 0000000000..3867769f6f --- /dev/null +++ b/src/backend/InvenTree/part/migrations/0136_partparameter_note_partparameter_updated_and_more.py @@ -0,0 +1,50 @@ +# Generated by Django 4.2.23 on 2025-07-15 02:05 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("part", "0135_alter_part_link"), + ] + + operations = [ + migrations.AddField( + model_name="partparameter", + name="note", + field=models.CharField( + blank=True, + help_text="Optional note field", + max_length=500, + verbose_name="Note", + ), + ), + migrations.AddField( + model_name="partparameter", + name="updated", + field=models.DateTimeField( + blank=True, + default=None, + help_text="Timestamp of last update", + null=True, + verbose_name="Updated", + ), + ), + migrations.AddField( + model_name="partparameter", + name="updated_by", + field=models.ForeignKey( + blank=True, + help_text="User who last updated this object", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated", + to=settings.AUTH_USER_MODEL, + verbose_name="Update By", + ), + ), + ] diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index 166db4a1c3..57fe3f21d7 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -3976,13 +3976,19 @@ def post_save_part_parameter_template(sender, instance, created, **kwargs): ) -class PartParameter(InvenTree.models.InvenTreeMetadataModel): +class PartParameter( + common.models.UpdatedUserMixin, InvenTree.models.InvenTreeMetadataModel +): """A PartParameter is a specific instance of a PartParameterTemplate. It assigns a particular parameter pair to a part. Attributes: part: Reference to a single Part object template: Reference to a single PartParameterTemplate object data: The data (value) of the Parameter [string] + data_numeric: Numeric value of the parameter (if applicable) [float] + note: Optional note field for the parameter [string] + updated: Timestamp of when the parameter was last updated [datetime] + updated_by: Reference to the User who last updated the parameter [User] """ class Meta: @@ -4123,6 +4129,13 @@ class PartParameter(InvenTree.models.InvenTreeMetadataModel): data_numeric = models.FloatField(default=None, null=True, blank=True) + note = models.CharField( + max_length=500, + blank=True, + verbose_name=_('Note'), + help_text=_('Optional note field'), + ) + @property def units(self): """Return the units associated with the template.""" diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 049256cc8f..3fef3b1634 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -410,8 +410,14 @@ class PartParameterSerializer( 'template_detail', 'data', 'data_numeric', + 'note', + 'updated', + 'updated_by', + 'updated_by_detail', ] + read_only_fields = ['updated', 'updated_by'] + def __init__(self, *args, **kwargs): """Custom initialization method for the serializer. @@ -431,13 +437,27 @@ class PartParameterSerializer( if not template_detail: self.fields.pop('template_detail', None) + def save(self): + """Save the PartParameter instance.""" + instance = super().save() + + if request := self.context.get('request', None): + # If the request is provided, update the 'updated_by' field + instance.updated_by = request.user + instance.save() + + return instance + part_detail = PartBriefSerializer( source='part', many=False, read_only=True, allow_null=True ) + template_detail = PartParameterTemplateSerializer( source='template', many=False, read_only=True, allow_null=True ) + updated_by_detail = UserSerializer(source='updated_by', many=False, read_only=True) + class DuplicatePartSerializer(serializers.Serializer): """Serializer for specifying options when duplicating a Part. @@ -1134,9 +1154,8 @@ class PartSerializer( part=instance, quantity=quantity, location=location ) - request = self.context.get('request', None) - user = request.user if request else None - stockitem.save(user=user) + if request := self.context.get('request', None): + stockitem.save(user=request.user) # Create initial supplier information if initial_supplier: @@ -1302,7 +1321,7 @@ class PartStocktakeSerializer(InvenTree.serializers.InvenTreeModelSerializer): # Add in user information automatically request = self.context.get('request') data['user'] = request.user if request else None - super().save() + return super().save() class PartStocktakeReportSerializer(InvenTree.serializers.InvenTreeModelSerializer): diff --git a/src/backend/InvenTree/part/test_param.py b/src/backend/InvenTree/part/test_param.py index 4cf8965f00..af2c162d53 100644 --- a/src/backend/InvenTree/part/test_param.py +++ b/src/backend/InvenTree/part/test_param.py @@ -1,6 +1,7 @@ """Various unit tests for Part Parameters.""" import django.core.exceptions as django_exceptions +from django.contrib.auth.models import User from django.test import TestCase, TransactionTestCase from django.urls import reverse @@ -19,7 +20,7 @@ from .models import ( class TestParams(TestCase): """Unit test class for testing the PartParameter model.""" - fixtures = ['location', 'category', 'part', 'params'] + fixtures = ['location', 'category', 'part', 'params', 'users'] def test_str(self): """Test the str representation of the PartParameterTemplate model.""" @@ -32,6 +33,18 @@ class TestParams(TestCase): c1 = PartCategoryParameterTemplate.objects.get(pk=1) self.assertEqual(str(c1), 'Mechanical | Length | 2.8') + def test_updated(self): + """Test that the 'updated' field is correctly set.""" + p1 = PartParameter.objects.get(pk=1) + self.assertIsNone(p1.updated) + self.assertIsNone(p1.updated_by) + + user = User.objects.get(username='sam') + p1.save(updated_by=user) + + self.assertIsNotNone(p1.updated) + self.assertEqual(p1.updated_by, user) + def test_validate(self): """Test validation for part templates.""" n = PartParameterTemplate.objects.all().count() diff --git a/src/frontend/src/forms/PartForms.tsx b/src/frontend/src/forms/PartForms.tsx index 22341e49e1..db7dcba159 100644 --- a/src/frontend/src/forms/PartForms.tsx +++ b/src/frontend/src/forms/PartForms.tsx @@ -250,7 +250,8 @@ export function usePartParameterFields({ // Coerce boolean value into a string (required by backend) return value.toString(); } - } + }, + note: {} }; }, [editTemplate, fieldType, choices]); } diff --git a/src/frontend/src/tables/part/PartParameterTable.tsx b/src/frontend/src/tables/part/PartParameterTable.tsx index e2e3b1364e..0b2a2930bb 100644 --- a/src/frontend/src/tables/part/PartParameterTable.tsx +++ b/src/frontend/src/tables/part/PartParameterTable.tsx @@ -12,9 +12,11 @@ import { YesNoButton } from '@lib/components/YesNoButton'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { UserRoles } from '@lib/enums/Roles'; import { apiUrl } from '@lib/functions/Api'; +import type { TableFilter } from '@lib/types/Filters'; import type { ApiFormFieldSet } from '@lib/types/Forms'; import type { TableColumn } from '@lib/types/Tables'; import { AddItemButton } from '../../components/buttons/AddItemButton'; +import { RenderUser } from '../../components/render/User'; import { formatDecimal } from '../../defaults/formatters'; import { usePartParameterFields } from '../../forms/PartForms'; import { @@ -24,7 +26,13 @@ import { } from '../../hooks/UseForm'; import { useTable } from '../../hooks/UseTable'; import { useUserState } from '../../states/UserState'; -import { DescriptionColumn, PartColumn } from '../ColumnRenderers'; +import { + DateColumn, + DescriptionColumn, + NoteColumn, + PartColumn +} from '../ColumnRenderers'; +import { UserFilter } from '../Filter'; import { InvenTreeTable } from '../InvenTreeTable'; import { TableHoverCard } from '../TableHoverCard'; @@ -108,10 +116,45 @@ export function PartParameterTable({ accessor: 'template_detail.units', ordering: 'units', sortable: true + }, + NoteColumn({}), + DateColumn({ + accessor: 'updated', + title: t`Last Updated`, + sortable: true, + switchable: true + }), + { + accessor: 'updated_by', + title: t`Updated By`, + sortable: true, + switchable: true, + render: (record: any) => { + return record.updated_by_detail ? ( + + ) : ( + '-' + ); + } } ]; }, [partId]); + const tableFilters: TableFilter[] = useMemo(() => { + return [ + { + name: 'include_variants', + label: t`Include Variants`, + type: 'boolean' + }, + UserFilter({ + name: 'updated_by', + label: t`Updated By`, + description: t`Filter by user who last updated the parameter` + }) + ]; + }, []); + const partParameterFields: ApiFormFieldSet = usePartParameterFields({}); const newParameter = useCreateApiFormModal({ @@ -211,13 +254,7 @@ export function PartParameterTable({ rowActions: rowActions, enableDownload: true, tableActions: tableActions, - tableFilters: [ - { - name: 'include_variants', - label: t`Include Variants`, - type: 'boolean' - } - ], + tableFilters: tableFilters, params: { part: partId, template_detail: true, diff --git a/src/frontend/tests/pages/pui_part.spec.ts b/src/frontend/tests/pages/pui_part.spec.ts index 994ef8f574..fa2998aa19 100644 --- a/src/frontend/tests/pages/pui_part.spec.ts +++ b/src/frontend/tests/pages/pui_part.spec.ts @@ -419,6 +419,8 @@ test('Parts - Parameters', async ({ browser }) => { await page.getByLabel('choice-field-data').click(); await page.getByRole('option', { name: 'Green' }).click(); + await page.getByLabel('text-field-note').fill('A custom note field'); + // Select the "polarized" parameter template (should create a "checkbox" field) await page.getByLabel('related-field-template').fill('Polarized'); await page.getByText('Is this part polarized?').click();