2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-18 13:05:42 +00:00

Merge branch 'master' of https://github.com/inventree/InvenTree into matmair/issue3005

This commit is contained in:
Matthias Mair
2022-05-16 17:48:01 +02:00
126 changed files with 13394 additions and 12710 deletions

View File

@ -2,9 +2,6 @@
Provides a JSON API for the Part app
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import datetime
from django.urls import include, path, re_path
@ -44,6 +41,7 @@ from stock.models import StockItem, StockLocation
from common.models import InvenTreeSetting
from build.models import Build, BuildItem
import order.models
from plugin.serializers import MetadataSerializer
from . import serializers as part_serializers
@ -203,6 +201,15 @@ class CategoryDetail(generics.RetrieveUpdateDestroyAPIView):
return response
class CategoryMetadata(generics.RetrieveUpdateAPIView):
"""API endpoint for viewing / updating PartCategory metadata"""
def get_serializer(self, *args, **kwargs):
return MetadataSerializer(PartCategory, *args, **kwargs)
queryset = PartCategory.objects.all()
class CategoryParameterList(generics.ListAPIView):
""" API endpoint for accessing a list of PartCategoryParameterTemplate objects.
@ -587,6 +594,17 @@ class PartScheduling(generics.RetrieveAPIView):
return Response(schedule)
class PartMetadata(generics.RetrieveUpdateAPIView):
"""
API endpoint for viewing / updating Part metadata
"""
def get_serializer(self, *args, **kwargs):
return MetadataSerializer(Part, *args, **kwargs)
queryset = Part.objects.all()
class PartSerialNumberDetail(generics.RetrieveAPIView):
"""
API endpoint for returning extra serial number information about a particular part
@ -1912,7 +1930,15 @@ part_api_urls = [
re_path(r'^tree/', CategoryTree.as_view(), name='api-part-category-tree'),
re_path(r'^parameters/', CategoryParameterList.as_view(), name='api-part-category-parameter-list'),
re_path(r'^(?P<pk>\d+)/?', CategoryDetail.as_view(), name='api-part-category-detail'),
# Category detail endpoints
re_path(r'^(?P<pk>\d+)/', include([
re_path(r'^metadata/', CategoryMetadata.as_view(), name='api-part-category-metadata'),
# PartCategory detail endpoint
re_path(r'^.*$', CategoryDetail.as_view(), name='api-part-category-detail'),
])),
path('', CategoryList.as_view(), name='api-part-category-list'),
])),
@ -1973,6 +1999,9 @@ part_api_urls = [
# Endpoint for validating a BOM for the specific Part
re_path(r'^bom-validate/', PartValidateBOM.as_view(), name='api-part-bom-validate'),
# Part metadata
re_path(r'^metadata/', PartMetadata.as_view(), name='api-part-metadata'),
# Part detail endpoint
re_path(r'^.*$', PartDetail.as_view(), name='api-part-detail'),
])),

View File

@ -2,9 +2,6 @@
Django Forms for interacting with Part objects
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django import forms
from django.utils.translation import gettext_lazy as _

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.13 on 2022-05-16 08:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0075_auto_20211128_0151'),
]
operations = [
migrations.AddField(
model_name='part',
name='metadata',
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
),
migrations.AddField(
model_name='partcategory',
name='metadata',
field=models.JSONField(blank=True, help_text='JSON metadata field, for use by external plugins', null=True, verbose_name='Plugin Metadata'),
),
]

View File

@ -2,8 +2,6 @@
Part database model definitions
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import decimal
import os
@ -46,30 +44,29 @@ from common.models import InvenTreeSetting
from InvenTree import helpers
from InvenTree import validators
from InvenTree.models import InvenTreeTree, InvenTreeAttachment, DataImportMixin
from InvenTree.fields import InvenTreeURLField
from InvenTree.helpers import decimal2string, normalize, decimal2money
import InvenTree.ready
import InvenTree.tasks
from InvenTree.fields import InvenTreeURLField
from InvenTree.helpers import decimal2string, normalize, decimal2money
from InvenTree.models import InvenTreeTree, InvenTreeAttachment, DataImportMixin
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus
import common.models
from build import models as BuildModels
from order import models as OrderModels
from company.models import SupplierPart
from stock import models as StockModels
import common.models
import part.settings as part_settings
from stock import models as StockModels
from part import tasks as part_tasks
from plugin.models import MetadataMixin
logger = logging.getLogger("inventree")
class PartCategory(InvenTreeTree):
class PartCategory(MetadataMixin, InvenTreeTree):
""" PartCategory provides hierarchical organization of Part objects.
Attributes:
@ -328,7 +325,7 @@ class PartManager(TreeManager):
@cleanup.ignore
class Part(MPTTModel):
class Part(MetadataMixin, MPTTModel):
""" The Part object represents an abstract part, the 'concept' of an actual entity.
An actual physical instance of a Part is a StockItem which is treated separately.
@ -445,7 +442,7 @@ class Part(MPTTModel):
previous = Part.objects.get(pk=self.pk)
# Image has been changed
if previous.image is not None and not self.image == previous.image:
if previous.image is not None and self.image != previous.image:
# Are there any (other) parts which reference the image?
n_refs = Part.objects.filter(image=previous.image).exclude(pk=self.pk).count()
@ -2896,7 +2893,7 @@ class BomItem(models.Model, DataImportMixin):
# If the sub_part is 'trackable' then the 'quantity' field must be an integer
if self.sub_part.trackable:
if not self.quantity == int(self.quantity):
if self.quantity != int(self.quantity):
raise ValidationError({
"quantity": _("Quantity must be integer value for trackable parts")
})

View File

@ -2,9 +2,6 @@
User-configurable settings for the Part app
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from common.models import InvenTreeSetting

View File

@ -589,32 +589,15 @@
// Get a list of the selected BOM items
var rows = $("#bom-table").bootstrapTable('getSelections');
// TODO - In the future, display (in the dialog) which items are going to be deleted
if (rows.length == 0) {
rows = $('#bom-table').bootstrapTable('getData');
}
showQuestionDialog(
'{% trans "Delete selected BOM items?" %}',
'{% trans "All selected BOM items will be deleted" %}',
{
accept: function() {
// Keep track of each DELETE request
var requests = [];
rows.forEach(function(row) {
requests.push(
inventreeDelete(
`/api/bom/${row.pk}/`,
)
);
});
// Wait for *all* the requests to complete
$.when.apply($, requests).done(function() {
location.reload();
});
}
deleteBomItems(rows, {
success: function() {
$('#bom-table').bootstrapTable('refresh');
}
);
});
});
$('#bom-upload').click(function() {

View File

@ -21,6 +21,85 @@ import build.models
import order.models
class PartCategoryAPITest(InvenTreeAPITestCase):
"""Unit tests for the PartCategory API"""
fixtures = [
'category',
'part',
'location',
'bom',
'company',
'test_templates',
'manufacturer_part',
'supplier_part',
'order',
'stock',
]
roles = [
'part.change',
'part.add',
'part.delete',
'part_category.change',
'part_category.add',
]
def test_category_list(self):
# List all part categories
url = reverse('api-part-category-list')
response = self.get(url, expected_code=200)
self.assertEqual(len(response.data), 8)
# Filter by parent, depth=1
response = self.get(
url,
{
'parent': 1,
'cascade': False,
},
expected_code=200
)
self.assertEqual(len(response.data), 3)
# Filter by parent, cascading
response = self.get(
url,
{
'parent': 1,
'cascade': True,
},
expected_code=200,
)
self.assertEqual(len(response.data), 5)
def test_category_metadata(self):
"""Test metadata endpoint for the PartCategory"""
cat = PartCategory.objects.get(pk=1)
cat.metadata = {
'foo': 'bar',
'water': 'melon',
'abc': 'xyz',
}
cat.set_metadata('abc', 'ABC')
response = self.get(reverse('api-part-category-metadata', kwargs={'pk': 1}), expected_code=200)
metadata = response.data['metadata']
self.assertEqual(metadata['foo'], 'bar')
self.assertEqual(metadata['water'], 'melon')
self.assertEqual(metadata['abc'], 'ABC')
class PartOptionsAPITest(InvenTreeAPITestCase):
"""
Tests for the various OPTIONS endpoints in the /part/ API
@ -1021,6 +1100,59 @@ class PartDetailTests(InvenTreeAPITestCase):
self.assertEqual(data['in_stock'], 9000)
self.assertEqual(data['unallocated_stock'], 9000)
def test_part_metadata(self):
"""
Tests for the part metadata endpoint
"""
url = reverse('api-part-metadata', kwargs={'pk': 1})
part = Part.objects.get(pk=1)
# Metadata is initially null
self.assertIsNone(part.metadata)
part.metadata = {'foo': 'bar'}
part.save()
response = self.get(url, expected_code=200)
self.assertEqual(response.data['metadata']['foo'], 'bar')
# Add more data via the API
# Using the 'patch' method causes the new data to be merged in
self.patch(
url,
{
'metadata': {
'hello': 'world',
}
},
expected_code=200
)
part.refresh_from_db()
self.assertEqual(part.metadata['foo'], 'bar')
self.assertEqual(part.metadata['hello'], 'world')
# Now, issue a PUT request (existing data will be replacted)
self.put(
url,
{
'metadata': {
'x': 'y'
},
},
expected_code=200
)
part.refresh_from_db()
self.assertFalse('foo' in part.metadata)
self.assertFalse('hello' in part.metadata)
self.assertEqual(part.metadata['x'], 'y')
class PartAPIAggregationTest(InvenTreeAPITestCase):
"""

View File

@ -1,8 +1,5 @@
# Tests for Part Parameters
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.test import TestCase, TransactionTestCase
import django.core.exceptions as django_exceptions

View File

@ -199,7 +199,7 @@ class PartTest(TestCase):
with self.assertRaises(ValidationError):
part_2.validate_unique()
def test_metadata(self):
def test_attributes(self):
self.assertEqual(self.r1.name, 'R_2K2_0805')
self.assertEqual(self.r1.get_absolute_url(), '/part/3/')
@ -245,6 +245,24 @@ class PartTest(TestCase):
self.assertEqual(float(self.r1.get_internal_price(1)), 0.08)
self.assertEqual(float(self.r1.get_internal_price(10)), 0.5)
def test_metadata(self):
"""Unit tests for the Part metadata field"""
p = Part.objects.get(pk=1)
self.assertIsNone(p.metadata)
self.assertIsNone(p.get_metadata('test'))
self.assertEqual(p.get_metadata('test', backup_value=123), 123)
# Test update via the set_metadata() method
p.set_metadata('test', 3)
self.assertEqual(p.get_metadata('test'), 3)
for k in ['apple', 'banana', 'carrot', 'carrot', 'banana']:
p.set_metadata(k, k)
self.assertEqual(len(p.metadata.keys()), 4)
class TestTemplateTest(TestCase):

View File

@ -2,9 +2,6 @@
Django views for interacting with Part app
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.core.files.base import ContentFile
from django.core.exceptions import ValidationError
from django.db import transaction
@ -628,7 +625,7 @@ class PartImageDownloadFromURL(AjaxUpdateView):
self.response = response
# Check for valid response code
if not response.status_code == 200:
if response.status_code != 200:
form.add_error('url', _('Invalid response: {code}').format(code=response.status_code))
return