mirror of
https://github.com/inventree/InvenTree.git
synced 2025-07-17 18:26:32 +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:
Binary file not shown.
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 32 KiB |
@@ -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
|
||||
|
@@ -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."""
|
||||
|
||||
|
@@ -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()
|
||||
|
||||
|
@@ -121,6 +121,8 @@ class ParameterAdmin(admin.ModelAdmin):
|
||||
|
||||
list_display = ('part', 'template', 'data')
|
||||
|
||||
readonly_fields = ('updated', 'updated_by')
|
||||
|
||||
autocomplete_fields = ('part', 'template')
|
||||
|
||||
|
||||
|
@@ -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',
|
||||
|
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
@@ -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."""
|
||||
|
@@ -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):
|
||||
|
@@ -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()
|
||||
|
@@ -250,7 +250,8 @@ export function usePartParameterFields({
|
||||
// Coerce boolean value into a string (required by backend)
|
||||
return value.toString();
|
||||
}
|
||||
}
|
||||
},
|
||||
note: {}
|
||||
};
|
||||
}, [editTemplate, fieldType, choices]);
|
||||
}
|
||||
|
@@ -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,
|
||||
|
@@ -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();
|
||||
|
Reference in New Issue
Block a user