2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-18 10:46:31 +00:00

PartParameter updates (#10023)

* Add mixin for storing user who last updated an instance

* Add mixin to "PartParameter" model

* Fix typo

* Fix strings

* Refactor mixin class

* Update part parameter table:

- Add "user" filter
- Add "updated_by" column
- Add "update" column
- Add "note" column

* Fix for updating date

* Add user information when saving parameter

* small refactors

* Bump API version

* Add unit test for "updated" and "updated_by" fields

* Check for 'note' field

* Update docs image
This commit is contained in:
Oliver
2025-07-15 22:34:07 +10:00
committed by GitHub
parent 75ab57bc0b
commit d99ec3e1a1
13 changed files with 203 additions and 22 deletions

View File

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

View File

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

View File

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

View File

@@ -121,6 +121,8 @@ class ParameterAdmin(admin.ModelAdmin):
list_display = ('part', 'template', 'data')
readonly_fields = ('updated', 'updated_by')
autocomplete_fields = ('part', 'template')

View File

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

View File

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

View File

@@ -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 <key:value> 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."""

View File

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

View File

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

View File

@@ -250,7 +250,8 @@ export function usePartParameterFields({
// Coerce boolean value into a string (required by backend)
return value.toString();
}
}
},
note: {}
};
}, [editTemplate, fieldType, choices]);
}

View File

@@ -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 ? (
<RenderUser instance={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,

View File

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