Add Metadata to further models (#4410)

* Add metadata for ManufacturerPart

* Add Metadata for SupplierPart

* Add metadata to label models

* Add metadata to order line items

* Add metadata to shipment

* Add metadata to Build and BuildItem

* Add metadata to BomItem

* Add metadata to PartParameterTemplate

* Syntax, lint

* Fix isort style

* Lint

* Correction of model name

* Metadata for Reports

* Fix silly error

* Fix silly error

* Correct model name

* Correct model name

* Correction

* Correct company urls

* Apply generic model to Report metadat

* Rename/remove redundant import

* Remove shadowing of report in loop variable

* Update import ordering

* More corrections

* better docstrings

* Correct names for API endpoints

* Default to PO, required for api-doc to work

* Changes by @matmair

* Suppress metadata from Bom export

* Add migration files

* Increment API version

* Add tests for all Metadata models, even previously existing ones

* Update tests

* Fix

* Delay tests

* Fix imports

* Fix tests

* API Version number

* Remove unused import

* isort

* Revent  unintended change of cache
This commit is contained in:
miggland 2023-03-23 11:51:08 +01:00 committed by GitHub
parent 4e72ac303f
commit 807784d9e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 642 additions and 40 deletions

View File

@ -28,7 +28,7 @@ repos:
hooks:
- id: isort
- repo: https://github.com/jazzband/pip-tools
rev: 6.12.2
rev: 6.12.3
hooks:
- id: pip-compile
name: pip-compile requirements-dev.in

View File

@ -2,11 +2,14 @@
# InvenTree API version
INVENTREE_API_VERSION = 102
INVENTREE_API_VERSION = 103
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v103 -> 2023-03-17 : https://github.com/inventree/InvenTree/pull/4410
- Add metadata to several more models
v102 -> 2023-03-18 : https://github.com/inventree/InvenTree/pull/4505
- Adds global search API endpoint for consolidated search results

View File

@ -14,7 +14,9 @@ from InvenTree.api import AttachmentMixin, APIDownloadMixin, ListCreateDestroyAP
from InvenTree.helpers import str2bool, isNull, DownloadFile
from InvenTree.filters import InvenTreeOrderingFilter
from InvenTree.status_codes import BuildStatus
from InvenTree.mixins import CreateAPI, RetrieveUpdateDestroyAPI, ListCreateAPI
from InvenTree.mixins import CreateAPI, RetrieveUpdateAPI, RetrieveUpdateDestroyAPI, ListCreateAPI
from plugin.serializers import MetadataSerializer
import build.admin
import build.serializers
@ -290,6 +292,16 @@ class BuildOrderContextMixin:
return ctx
class BuildOrderMetadata(RetrieveUpdateAPI):
"""API endpoint for viewing / updating BuildOrder metadata."""
def get_serializer(self, *args, **kwargs):
"""Return MetadataSerializer instance"""
return MetadataSerializer(Build, *args, **kwargs)
queryset = Build.objects.all()
class BuildOutputCreate(BuildOrderContextMixin, CreateAPI):
"""API endpoint for creating new build output(s)."""
@ -461,6 +473,16 @@ class BuildItemList(ListCreateAPI):
]
class BuildItemMetadata(RetrieveUpdateAPI):
"""API endpoint for viewing / updating BuildItem metadata."""
def get_serializer(self, *args, **kwargs):
"""Return MetadataSerializer instance"""
return MetadataSerializer(BuildItem, *args, **kwargs)
queryset = BuildItem.objects.all()
class BuildAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing (and creating) BuildOrderAttachment objects."""
@ -493,7 +515,10 @@ build_api_urls = [
# Build Items
re_path(r'^item/', include([
re_path(r'^(?P<pk>\d+)/', BuildItemDetail.as_view(), name='api-build-item-detail'),
re_path(r'^(?P<pk>\d+)/', include([
re_path(r'^metadata/', BuildItemMetadata.as_view(), name='api-build-item-metadata'),
re_path(r'^.*$', BuildItemDetail.as_view(), name='api-build-item-detail'),
])),
re_path(r'^.*$', BuildItemList.as_view(), name='api-build-item-list'),
])),
@ -507,6 +532,7 @@ build_api_urls = [
re_path(r'^finish/', BuildFinish.as_view(), name='api-build-finish'),
re_path(r'^cancel/', BuildCancel.as_view(), name='api-build-cancel'),
re_path(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
re_path(r'^metadata/', BuildOrderMetadata.as_view(), name='api-build-metadata'),
re_path(r'^.*$', BuildDetail.as_view(), name='api-build-detail'),
])),

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.18 on 2023-03-17 08:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('build', '0038_alter_build_responsible'),
]
operations = [
migrations.AddField(
model_name='build',
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='builditem',
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

@ -33,6 +33,7 @@ import InvenTree.ready
import InvenTree.tasks
from plugin.events import trigger_event
from plugin.models import MetadataMixin
import common.notifications
from part import models as PartModels
@ -40,7 +41,7 @@ from stock import models as StockModels
from users import models as UserModels
class Build(MPTTModel, ReferenceIndexingMixin):
class Build(MPTTModel, MetadataMixin, ReferenceIndexingMixin):
"""A Build object organises the creation of new StockItem objects from other existing StockItem objects.
Attributes:
@ -1140,7 +1141,7 @@ class BuildOrderAttachment(InvenTreeAttachment):
build = models.ForeignKey(Build, on_delete=models.CASCADE, related_name='attachments')
class BuildItem(models.Model):
class BuildItem(MetadataMixin, models.Model):
"""A BuildItem links multiple StockItem objects to a Build.
These are used to allocate part stock to a build. Once the Build is completed, the parts are removed from stock and the BuildItemAllocation objects are removed.

View File

@ -570,6 +570,29 @@ class BuildTest(BuildTestBase):
self.assertTrue(messages.filter(user__pk=4).exists())
def test_metadata(self):
"""Unit tests for the metadata field."""
# Make sure a BuildItem exists before trying to run this test
b = BuildItem(stock_item=self.stock_1_2, build=self.build, install_into=self.output_1, quantity=10)
b.save()
for model in [Build, BuildItem]:
p = model.objects.first()
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 AutoAllocationTests(BuildTestBase):
"""Tests for auto allocating stock against a build order"""

View File

@ -195,6 +195,16 @@ class ManufacturerPartDetail(RetrieveUpdateDestroyAPI):
serializer_class = ManufacturerPartSerializer
class ManufacturerPartMetadata(RetrieveUpdateAPI):
"""API endpoint for viewing / updating ManufacturerPart metadata."""
def get_serializer(self, *args, **kwargs):
"""Return MetadataSerializer instance for a Company"""
return MetadataSerializer(ManufacturerPart, *args, **kwargs)
queryset = ManufacturerPart.objects.all()
class ManufacturerPartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing (and creating) a ManufacturerPartAttachment (file upload)."""
@ -424,6 +434,16 @@ class SupplierPartDetail(RetrieveUpdateDestroyAPI):
]
class SupplierPartMetadata(RetrieveUpdateAPI):
"""API endpoint for viewing / updating SupplierPart metadata."""
def get_serializer(self, *args, **kwargs):
"""Return MetadataSerializer instance for a Company"""
return MetadataSerializer(SupplierPart, *args, **kwargs)
queryset = SupplierPart.objects.all()
class SupplierPriceBreakFilter(rest_filters.FilterSet):
"""Custom API filters for the SupplierPriceBreak list endpoint"""
@ -510,7 +530,10 @@ manufacturer_part_api_urls = [
re_path(r'^.*$', ManufacturerPartParameterList.as_view(), name='api-manufacturer-part-parameter-list'),
])),
re_path(r'^(?P<pk>\d+)/?', ManufacturerPartDetail.as_view(), name='api-manufacturer-part-detail'),
re_path(r'^(?P<pk>\d+)/?', include([
re_path('^metadata/', ManufacturerPartMetadata.as_view(), name='api-manufacturer-part-metadata'),
re_path('^.*$', ManufacturerPartDetail.as_view(), name='api-manufacturer-part-detail'),
])),
# Catch anything else
re_path(r'^.*$', ManufacturerPartList.as_view(), name='api-manufacturer-part-list'),
@ -519,7 +542,10 @@ manufacturer_part_api_urls = [
supplier_part_api_urls = [
re_path(r'^(?P<pk>\d+)/?', SupplierPartDetail.as_view(), name='api-supplier-part-detail'),
re_path(r'^(?P<pk>\d+)/?', include([
re_path('^metadata/', SupplierPartMetadata.as_view(), name='api-supplier-part-metadata'),
re_path('^.*$', SupplierPartDetail.as_view(), name='api-supplier-part-detail'),
])),
# Catch anything else
re_path(r'^.*$', SupplierPartList.as_view(), name='api-supplier-part-list'),

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.18 on 2023-03-17 08:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('company', '0054_companyattachment'),
]
operations = [
migrations.AddField(
model_name='manufacturerpart',
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='supplierpart',
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

@ -247,7 +247,7 @@ class Contact(models.Model):
role = models.CharField(max_length=100, blank=True)
class ManufacturerPart(models.Model):
class ManufacturerPart(MetadataMixin, models.Model):
"""Represents a unique part as provided by a Manufacturer Each ManufacturerPart is identified by a MPN (Manufacturer Part Number) Each ManufacturerPart is also linked to a Part object. A Part may be available from multiple manufacturers.
Attributes:
@ -415,7 +415,7 @@ class SupplierPartManager(models.Manager):
)
class SupplierPart(InvenTreeBarcodeMixin, common.models.MetaMixin):
class SupplierPart(MetadataMixin, InvenTreeBarcodeMixin, common.models.MetaMixin):
"""Represents a unique part as provided by a Supplier Each SupplierPart is identified by a SKU (Supplier Part Number) Each SupplierPart is also linked to a Part or ManufacturerPart object. A Part may be available from multiple suppliers.
Attributes:

View File

@ -132,6 +132,23 @@ class CompanySimpleTest(TestCase):
with self.assertRaises(ValidationError):
company.full_clean()
def test_metadata(self):
"""Unit tests for the metadata field."""
p = Company.objects.first()
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 ContactSimpleTest(TestCase):
"""Unit tests for the Contact model"""
@ -205,3 +222,21 @@ class ManufacturerPartSimpleTest(TestCase):
Part.objects.get(pk=self.part.id).delete()
# Check that ManufacturerPart was deleted
self.assertEqual(ManufacturerPart.objects.count(), 3)
def test_metadata(self):
"""Unit tests for the metadata field."""
for model in [ManufacturerPart, SupplierPart]:
p = model.objects.first()
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)

View File

@ -13,11 +13,13 @@ from rest_framework.exceptions import NotFound
import common.models
import InvenTree.helpers
from InvenTree.mixins import ListAPI, RetrieveAPI, RetrieveUpdateDestroyAPI
from InvenTree.mixins import (ListAPI, RetrieveAPI, RetrieveUpdateAPI,
RetrieveUpdateDestroyAPI)
from InvenTree.tasks import offload_task
from part.models import Part
from plugin.base.label import label as plugin_label
from plugin.registry import registry
from plugin.serializers import MetadataSerializer
from stock.models import StockItem, StockLocation
from .models import PartLabel, StockItemLabel, StockLocationLabel
@ -305,6 +307,16 @@ class StockItemLabelDetail(StockItemLabelMixin, RetrieveUpdateDestroyAPI):
pass
class StockItemLabelMetadata(RetrieveUpdateAPI):
"""API endpoint for viewing / updating StockItemLabel metadata."""
def get_serializer(self, *args, **kwargs):
"""Return MetadataSerializer instance for a Company"""
return MetadataSerializer(StockItemLabel, *args, **kwargs)
queryset = StockItemLabel.objects.all()
class StockItemLabelPrint(StockItemLabelMixin, LabelPrintMixin, RetrieveAPI):
"""API endpoint for printing a StockItemLabel object."""
pass
@ -337,6 +349,16 @@ class StockLocationLabelDetail(StockLocationLabelMixin, RetrieveUpdateDestroyAPI
pass
class StockLocationLabelMetadata(RetrieveUpdateAPI):
"""API endpoint for viewing / updating StockLocationLabel metadata."""
def get_serializer(self, *args, **kwargs):
"""Return MetadataSerializer instance for a Company"""
return MetadataSerializer(StockLocationLabel, *args, **kwargs)
queryset = StockLocationLabel.objects.all()
class StockLocationLabelPrint(StockLocationLabelMixin, LabelPrintMixin, RetrieveAPI):
"""API endpoint for printing a StockLocationLabel object."""
pass
@ -356,6 +378,16 @@ class PartLabelList(PartLabelMixin, LabelListView):
pass
class PartLabelMetadata(RetrieveUpdateAPI):
"""API endpoint for viewing / updating PartLabel metadata."""
def get_serializer(self, *args, **kwargs):
"""Return MetadataSerializer instance for a Company"""
return MetadataSerializer(PartLabel, *args, **kwargs)
queryset = PartLabel.objects.all()
class PartLabelDetail(PartLabelMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for a single PartLabel object."""
pass
@ -373,6 +405,7 @@ label_api_urls = [
# Detail views
re_path(r'^(?P<pk>\d+)/', include([
re_path(r'print/?', StockItemLabelPrint.as_view(), name='api-stockitem-label-print'),
re_path(r'metadata/', StockItemLabelMetadata.as_view(), name='api-stockitem-label-metadata'),
re_path(r'^.*$', StockItemLabelDetail.as_view(), name='api-stockitem-label-detail'),
])),
@ -385,6 +418,7 @@ label_api_urls = [
# Detail views
re_path(r'^(?P<pk>\d+)/', include([
re_path(r'print/?', StockLocationLabelPrint.as_view(), name='api-stocklocation-label-print'),
re_path(r'metadata/', StockLocationLabelMetadata.as_view(), name='api-stocklocation-label-metadata'),
re_path(r'^.*$', StockLocationLabelDetail.as_view(), name='api-stocklocation-label-detail'),
])),
@ -397,6 +431,7 @@ label_api_urls = [
# Detail views
re_path(r'^(?P<pk>\d+)/', include([
re_path(r'^print/', PartLabelPrint.as_view(), name='api-part-label-print'),
re_path(r'^metadata/', PartLabelMetadata.as_view(), name='api-part-label-metadata'),
re_path(r'^.*$', PartLabelDetail.as_view(), name='api-part-label-detail'),
])),

View File

@ -0,0 +1,28 @@
# Generated by Django 3.2.18 on 2023-03-17 08:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('label', '0008_auto_20210708_2106'),
]
operations = [
migrations.AddField(
model_name='partlabel',
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='stockitemlabel',
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='stocklocationlabel',
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

@ -17,6 +17,7 @@ import common.models
import part.models
import stock.models
from InvenTree.helpers import normalize, validateFilterString
from plugin.models import MetadataMixin
try:
from django_weasyprint import WeasyTemplateResponseMixin
@ -70,7 +71,7 @@ class WeasyprintLabelMixin(WeasyTemplateResponseMixin):
self.pdf_filename = kwargs.get('filename', 'label.pdf')
class LabelTemplate(models.Model):
class LabelTemplate(MetadataMixin, models.Model):
"""Base class for generic, filterable labels."""
class Meta:

View File

@ -130,3 +130,21 @@ class LabelTest(InvenTreeAPITestCase):
self.assertIn("http://testserver/part/1/", content)
self.assertIn("image: /static/img/blank_image.png", content)
self.assertIn("logo: /static/img/inventree.png", content)
def test_metadata(self):
"""Unit tests for the metadata field."""
for model in [StockItemLabel, StockLocationLabel, PartLabel]:
p = model.objects.first()
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)

View File

@ -604,6 +604,16 @@ class PurchaseOrderLineItemDetail(RetrieveUpdateDestroyAPI):
return queryset
class PurchaseOrderLineItemMetadata(RetrieveUpdateAPI):
"""API endpoint for viewing / updating PurchaseOrderLineItem metadata."""
def get_serializer(self, *args, **kwargs):
"""Return MetadataSerializer instance for a Company"""
return MetadataSerializer(models.PurchaseOrderLineItem, *args, **kwargs)
queryset = models.PurchaseOrderLineItem.objects.all()
class PurchaseOrderExtraLineList(GeneralExtraLineList, ListCreateAPI):
"""API endpoint for accessing a list of PurchaseOrderExtraLine objects."""
@ -627,6 +637,16 @@ class PurchaseOrderExtraLineDetail(RetrieveUpdateDestroyAPI):
serializer_class = serializers.PurchaseOrderExtraLineSerializer
class PurchaseOrderExtraLineItemMetadata(RetrieveUpdateAPI):
"""API endpoint for viewing / updating PurchaseOrderExtraLineItem metadata."""
def get_serializer(self, *args, **kwargs):
"""Return MetadataSerializer instance"""
return MetadataSerializer(models.PurchaseOrderExtraLine, *args, **kwargs)
queryset = models.PurchaseOrderExtraLine.objects.all()
class SalesOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing (and creating) a SalesOrderAttachment (file upload)"""
@ -943,6 +963,16 @@ class SalesOrderLineItemList(APIDownloadMixin, ListCreateAPI):
]
class SalesOrderLineItemMetadata(RetrieveUpdateAPI):
"""API endpoint for viewing / updating SalesOrderLineItem metadata."""
def get_serializer(self, *args, **kwargs):
"""Return MetadataSerializer instance"""
return MetadataSerializer(models.SalesOrderLineItem, *args, **kwargs)
queryset = models.SalesOrderLineItem.objects.all()
class SalesOrderExtraLineList(GeneralExtraLineList, ListCreateAPI):
"""API endpoint for accessing a list of SalesOrderExtraLine objects."""
@ -966,6 +996,16 @@ class SalesOrderExtraLineDetail(RetrieveUpdateDestroyAPI):
serializer_class = serializers.SalesOrderExtraLineSerializer
class SalesOrderExtraLineItemMetadata(RetrieveUpdateAPI):
"""API endpoint for viewing / updating SalesOrderExtraLine metadata."""
def get_serializer(self, *args, **kwargs):
"""Return MetadataSerializer instance"""
return MetadataSerializer(models.SalesOrderExtraLine, *args, **kwargs)
queryset = models.SalesOrderExtraLine.objects.all()
class SalesOrderLineItemDetail(RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a SalesOrderLineItem object."""
@ -1191,6 +1231,16 @@ class SalesOrderShipmentComplete(CreateAPI):
return ctx
class SalesOrderShipmentMetadata(RetrieveUpdateAPI):
"""API endpoint for viewing / updating SalesOrderShipment metadata."""
def get_serializer(self, *args, **kwargs):
"""Return MetadataSerializer instance"""
return MetadataSerializer(models.SalesOrderShipment, *args, **kwargs)
queryset = models.SalesOrderShipment.objects.all()
class PurchaseOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload)"""
@ -1391,13 +1441,19 @@ order_api_urls = [
# API endpoints for purchase order line items
re_path(r'^po-line/', include([
path('<int:pk>/', PurchaseOrderLineItemDetail.as_view(), name='api-po-line-detail'),
path('<int:pk>/', include([
re_path(r'^metadata/', PurchaseOrderLineItemMetadata.as_view(), name='api-po-line-metadata'),
re_path(r'^.*$', PurchaseOrderLineItemDetail.as_view(), name='api-po-line-detail'),
])),
re_path(r'^.*$', PurchaseOrderLineItemList.as_view(), name='api-po-line-list'),
])),
# API endpoints for purchase order extra line
re_path(r'^po-extra-line/', include([
path('<int:pk>/', PurchaseOrderExtraLineDetail.as_view(), name='api-po-extra-line-detail'),
path('<int:pk>/', include([
re_path(r'^metadata/', PurchaseOrderExtraLineItemMetadata.as_view(), name='api-po-extra-line-metadata'),
re_path(r'^.*$', PurchaseOrderExtraLineDetail.as_view(), name='api-po-extra-line-detail'),
])),
path('', PurchaseOrderExtraLineList.as_view(), name='api-po-extra-line-list'),
])),
@ -1411,6 +1467,7 @@ order_api_urls = [
re_path(r'^shipment/', include([
re_path(r'^(?P<pk>\d+)/', include([
path('ship/', SalesOrderShipmentComplete.as_view(), name='api-so-shipment-ship'),
re_path(r'^metadata/', SalesOrderShipmentMetadata.as_view(), name='api-so-shipment-metadata'),
re_path(r'^.*$', SalesOrderShipmentDetail.as_view(), name='api-so-shipment-detail'),
])),
re_path(r'^.*$', SalesOrderShipmentList.as_view(), name='api-so-shipment-list'),
@ -1434,13 +1491,19 @@ order_api_urls = [
# API endpoints for sales order line items
re_path(r'^so-line/', include([
path('<int:pk>/', SalesOrderLineItemDetail.as_view(), name='api-so-line-detail'),
path('<int:pk>/', include([
re_path(r'^metadata/', SalesOrderLineItemMetadata.as_view(), name='api-so-line-metadata'),
re_path(r'^.*$', SalesOrderLineItemDetail.as_view(), name='api-so-line-detail'),
])),
path('', SalesOrderLineItemList.as_view(), name='api-so-line-list'),
])),
# API endpoints for sales order extra line
re_path(r'^so-extra-line/', include([
path('<int:pk>/', SalesOrderExtraLineDetail.as_view(), name='api-so-extra-line-detail'),
path('<int:pk>/', include([
re_path(r'^metadata/', SalesOrderExtraLineItemMetadata.as_view(), name='api-so-extra-line-metadata'),
re_path(r'^.*$', SalesOrderExtraLineDetail.as_view(), name='api-so-extra-line-detail'),
])),
path('', SalesOrderExtraLineList.as_view(), name='api-so-extra-line-list'),
])),

View File

@ -110,3 +110,11 @@
order: 1
part: 5
quantity: 1
# An extra line item
- model: order.purchaseorderextraline
pk: 1
fields:
order: 7
reference: 'Freight cost'
quantity: 1

View File

@ -0,0 +1,38 @@
# Generated by Django 3.2.18 on 2023-03-17 08:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('order', '0079_auto_20230304_0904'),
]
operations = [
migrations.AddField(
model_name='purchaseorderextraline',
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='purchaseorderlineitem',
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='salesorderextraline',
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='salesorderlineitem',
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='salesordershipment',
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

@ -938,7 +938,7 @@ class SalesOrderAttachment(InvenTreeAttachment):
order = models.ForeignKey(SalesOrder, on_delete=models.CASCADE, related_name='attachments')
class OrderLineItem(models.Model):
class OrderLineItem(MetadataMixin, models.Model):
"""Abstract model for an order line item.
Attributes:
@ -1256,7 +1256,7 @@ class SalesOrderLineItem(OrderLineItem):
return self.shipped >= self.quantity
class SalesOrderShipment(models.Model):
class SalesOrderShipment(MetadataMixin, models.Model):
"""The SalesOrderShipment model represents a physical shipment made against a SalesOrder.
- Points to a single SalesOrder object

View File

@ -11,7 +11,8 @@ import order.tasks
from common.models import InvenTreeSetting, NotificationMessage
from company.models import Company
from InvenTree import status_codes as status
from order.models import (SalesOrder, SalesOrderAllocation, SalesOrderLineItem,
from order.models import (SalesOrder, SalesOrderAllocation,
SalesOrderExtraLine, SalesOrderLineItem,
SalesOrderShipment)
from part.models import Part
from stock.models import StockItem
@ -54,6 +55,9 @@ class SalesOrderTest(TestCase):
# Create a line item
cls.line = SalesOrderLineItem.objects.create(quantity=50, order=cls.order, part=cls.part)
# Create an extra line
cls.extraline = SalesOrderExtraLine.objects.create(quantity=1, order=cls.order, reference="Extra line")
def test_so_reference(self):
"""Unit tests for sales order generation"""
@ -293,3 +297,22 @@ class SalesOrderTest(TestCase):
# However *no* notification should have been generated for the creating user
self.assertFalse(messages.filter(user__pk=3).exists())
def test_metadata(self):
"""Unit tests for the metadata field."""
for model in [SalesOrder, SalesOrderLineItem, SalesOrderExtraLine, SalesOrderShipment]:
p = model.objects.first()
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)

View File

@ -18,7 +18,8 @@ from part.models import Part
from stock.models import StockItem, StockLocation
from users.models import Owner
from .models import PurchaseOrder, PurchaseOrderLineItem
from .models import (PurchaseOrder, PurchaseOrderExtraLine,
PurchaseOrderLineItem)
class OrderTest(TestCase):
@ -384,3 +385,21 @@ class OrderTest(TestCase):
# However *no* notification should have been generated for the creating user
self.assertFalse(messages.filter(user__pk=3).exists())
def test_metadata(self):
"""Unit tests for the metadata field."""
for model in [PurchaseOrder, PurchaseOrderLineItem, PurchaseOrderExtraLine]:
p = model.objects.first()
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)

View File

@ -318,7 +318,7 @@ class BomItemResource(InvenTreeResource):
is_importing = getattr(self, 'is_importing', False)
include_pricing = getattr(self, 'include_pricing', False)
to_remove = []
to_remove = ['metadata']
if is_importing or not include_pricing:
# Remove pricing fields in this instance

View File

@ -1459,6 +1459,16 @@ class PartParameterTemplateDetail(RetrieveUpdateDestroyAPI):
serializer_class = part_serializers.PartParameterTemplateSerializer
class PartParameterTemplateMetadata(RetrieveUpdateAPI):
"""API endpoint for viewing / updating PartParameterTemplate metadata."""
def get_serializer(self, *args, **kwargs):
"""Return a MetadataSerializer pointing to the referenced PartParameterTemplate instance"""
return MetadataSerializer(PartParameterTemplate, *args, **kwargs)
queryset = PartParameterTemplate.objects.all()
class PartParameterList(ListCreateAPI):
"""API endpoint for accessing a list of PartParameter objects.
@ -1926,6 +1936,16 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI):
serializer_class = part_serializers.BomItemSubstituteSerializer
class BomItemMetadata(RetrieveUpdateAPI):
"""API endpoint for viewing / updating PartBOM metadata."""
def get_serializer(self, *args, **kwargs):
"""Return a MetadataSerializer pointing to the referenced PartCategory instance"""
return MetadataSerializer(BomItem, *args, **kwargs)
queryset = BomItem.objects.all()
part_api_urls = [
# Base URL for PartCategory API endpoints
@ -1933,8 +1953,8 @@ part_api_urls = [
re_path(r'^tree/', CategoryTree.as_view(), name='api-part-category-tree'),
re_path(r'^parameters/', include([
re_path('^(?P<pk>\d+)/', CategoryParameterDetail.as_view(), name='api-part-category-parameter-detail'),
re_path('^.*$', CategoryParameterList.as_view(), name='api-part-category-parameter-list'),
re_path(r'^(?P<pk>\d+)/', CategoryParameterDetail.as_view(), name='api-part-category-parameter-detail'),
re_path(r'^.*$', CategoryParameterList.as_view(), name='api-part-category-parameter-list'),
])),
# Category detail endpoints
@ -1982,7 +2002,10 @@ part_api_urls = [
# Base URL for PartParameter API endpoints
re_path(r'^parameter/', include([
path('template/', include([
re_path(r'^(?P<pk>\d+)/', PartParameterTemplateDetail.as_view(), name='api-part-parameter-template-detail'),
re_path(r'^(?P<pk>\d+)/', include([
re_path(r'^metadata/?', PartParameterTemplateMetadata.as_view(), name='api-part-parameter-template-metadata'),
re_path(r'^.*$', PartParameterTemplateDetail.as_view(), name='api-part-parameter-template-detail'),
])),
re_path(r'^.*$', PartParameterTemplateList.as_view(), name='api-part-parameter-template-list'),
])),
@ -2059,6 +2082,7 @@ bom_api_urls = [
# BOM Item Detail
re_path(r'^(?P<pk>\d+)/', include([
re_path(r'^validate/?', BomItemValidate.as_view(), name='api-bom-item-validate'),
re_path(r'^metadata/?', BomItemMetadata.as_view(), name='api-bom-item-metadata'),
re_path(r'^.*$', BomDetail.as_view(), name='api-bom-item-detail'),
])),

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.18 on 2023-03-17 08:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0102_auto_20230314_0112'),
]
operations = [
migrations.AddField(
model_name='bomitem',
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='partparametertemplate',
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

@ -3277,7 +3277,7 @@ def validate_template_name(name):
"""Placeholder for legacy function used in migrations."""
class PartParameterTemplate(models.Model):
class PartParameterTemplate(MetadataMixin, models.Model):
"""A PartParameterTemplate provides a template for key:value pairs for extra parameters fields/values to be added to a Part.
This allows users to arbitrarily assign data fields to a Part beyond the built-in attributes.
@ -3419,7 +3419,7 @@ class PartCategoryParameterTemplate(models.Model):
help_text=_('Default Parameter Value'))
class BomItem(DataImportMixin, models.Model):
class BomItem(DataImportMixin, MetadataMixin, models.Model):
"""A BomItem links a part to its component items.
A part can have a BOM (bill of materials) which defines

View File

@ -245,3 +245,21 @@ class BomItemTest(TestCase):
)
self.assertEqual(assembly.can_build, 20)
def test_metadata(self):
"""Unit tests for the metadata field."""
for model in [BomItem]:
p = model.objects.first()
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)

View File

@ -43,6 +43,24 @@ class TestParams(TestCase):
t3.full_clean()
t3.save() # pragma: no cover
def test_metadata(self):
"""Unit tests for the metadata field."""
for model in [PartParameterTemplate]:
p = model.objects.first()
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 TestCategoryTemplates(TransactionTestCase):
"""Test class for PartCategoryParameterTemplate model"""

View File

@ -274,8 +274,9 @@ class PartTest(TestCase):
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)
"""Unit tests for the metadata field."""
for model in [Part]:
p = model.objects.first()
self.assertIsNone(p.metadata)
self.assertIsNone(p.get_metadata('test'))

View File

@ -18,7 +18,9 @@ import common.models
import InvenTree.helpers
import order.models
import part.models
from InvenTree.mixins import ListAPI, RetrieveAPI, RetrieveUpdateDestroyAPI
from InvenTree.mixins import (ListAPI, RetrieveAPI, RetrieveUpdateAPI,
RetrieveUpdateDestroyAPI)
from plugin.serializers import MetadataSerializer
from stock.models import StockItem, StockItemAttachment
from .models import (BillOfMaterialsReport, BuildReport, PurchaseOrderReport,
@ -421,6 +423,31 @@ class SalesOrderReportPrint(SalesOrderReportMixin, ReportPrintMixin, RetrieveAPI
pass
class ReportMetadata(RetrieveUpdateAPI):
"""API endpoint for viewing / updating Report metadata."""
MODEL_REF = 'reportmodel'
def _get_model(self, *args, **kwargs):
"""Return model depending on which report type is requested in get_view constructor."""
reportmodel = self.kwargs.get(self.MODEL_REF, PurchaseOrderReport)
if reportmodel not in [PurchaseOrderReport, SalesOrderReport, BuildReport, BillOfMaterialsReport, TestReport]:
raise ValidationError("Invalid report model")
return reportmodel
# Return corresponding Serializer
def get_serializer(self, *args, **kwargs):
"""Return correct MetadataSerializer instance depending on which model is requested"""
# Get type of report, make sure its one of the allowed values
UseModel = self._get_model(*args, **kwargs)
return MetadataSerializer(UseModel, *args, **kwargs)
def get_queryset(self, *args, **kwargs):
"""Return correct queryset depending on which model is requested"""
UseModel = self._get_model(*args, **kwargs)
return UseModel.objects.all()
report_api_urls = [
# Purchase order reports
@ -428,6 +455,7 @@ report_api_urls = [
# Detail views
re_path(r'^(?P<pk>\d+)/', include([
re_path(r'print/', PurchaseOrderReportPrint.as_view(), name='api-po-report-print'),
re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: PurchaseOrderReport}, name='api-po-report-metadata'),
path('', PurchaseOrderReportDetail.as_view(), name='api-po-report-detail'),
])),
@ -440,6 +468,7 @@ report_api_urls = [
# Detail views
re_path(r'^(?P<pk>\d+)/', include([
re_path(r'print/', SalesOrderReportPrint.as_view(), name='api-so-report-print'),
re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: SalesOrderReport}, name='api-so-report-metadata'),
path('', SalesOrderReportDetail.as_view(), name='api-so-report-detail'),
])),
@ -451,6 +480,7 @@ report_api_urls = [
# Detail views
re_path(r'^(?P<pk>\d+)/', include([
re_path(r'print/?', BuildReportPrint.as_view(), name='api-build-report-print'),
re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: BuildReport}, name='api-build-report-metadata'),
re_path(r'^.$', BuildReportDetail.as_view(), name='api-build-report-detail'),
])),
@ -464,6 +494,7 @@ report_api_urls = [
# Detail views
re_path(r'^(?P<pk>\d+)/', include([
re_path(r'print/?', BOMReportPrint.as_view(), name='api-bom-report-print'),
re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: BillOfMaterialsReport}, name='api-bom-report-metadata'),
re_path(r'^.*$', BOMReportDetail.as_view(), name='api-bom-report-detail'),
])),
@ -476,6 +507,7 @@ report_api_urls = [
# Detail views
re_path(r'^(?P<pk>\d+)/', include([
re_path(r'print/?', StockItemTestReportPrint.as_view(), name='api-stockitem-testreport-print'),
re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: TestReport}, name='api-stockitem-testreport-metadata'),
re_path(r'^.*$', StockItemTestReportDetail.as_view(), name='api-stockitem-testreport-detail'),
])),

View File

@ -0,0 +1,38 @@
# Generated by Django 3.2.18 on 2023-03-17 08:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('report', '0016_auto_20210513_1303'),
]
operations = [
migrations.AddField(
model_name='billofmaterialsreport',
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='buildreport',
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='purchaseorderreport',
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='salesorderreport',
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='testreport',
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

@ -21,6 +21,7 @@ import order.models
import part.models
import stock.models
from InvenTree.helpers import validateFilterString
from plugin.models import MetadataMixin
try:
from django_weasyprint import WeasyTemplateResponseMixin
@ -174,7 +175,7 @@ class ReportBase(models.Model):
)
class ReportTemplateBase(ReportBase):
class ReportTemplateBase(MetadataMixin, ReportBase):
"""Reporting template model.
Able to be passed context data

View File

@ -258,7 +258,6 @@ class ReportTest(InvenTreeAPITestCase):
reports = self.model.objects.all()
n = len(reports)
# API endpoint must return correct number of reports
self.assertEqual(len(response.data), n)
@ -281,6 +280,25 @@ class ReportTest(InvenTreeAPITestCase):
response = self.get(url, {'enabled': False})
self.assertEqual(len(response.data), n)
def test_metadata(self):
"""Unit tests for the metadata field."""
if self.model is not None:
p = self.model.objects.first()
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 TestReportTest(ReportTest):
"""Unit testing class for the stock item TestReport model"""
@ -393,6 +411,12 @@ class BOMReportTest(ReportTest):
detail_url = 'api-bom-report-detail'
print_url = 'api-bom-report-print'
def setUp(self):
"""Setup function for the bill of materials Report"""
self.copyReportTemplate('inventree_bill_of_materials_report.html', 'bill of materials report')
return super().setUp()
class PurchaseOrderReportTest(ReportTest):
"""Unit test class fort he PurchaseOrderReport model"""
@ -402,6 +426,12 @@ class PurchaseOrderReportTest(ReportTest):
detail_url = 'api-po-report-detail'
print_url = 'api-po-report-print'
def setUp(self):
"""Setup function for the purchase order Report"""
self.copyReportTemplate('inventree_po_report.html', 'purchase order report')
return super().setUp()
class SalesOrderReportTest(ReportTest):
"""Unit test class for the SalesOrderReport model"""
@ -410,3 +440,9 @@ class SalesOrderReportTest(ReportTest):
list_url = 'api-so-report-list'
detail_url = 'api-so-report-detail'
print_url = 'api-so-report-print'
def setUp(self):
"""Setup function for the sales order Report"""
self.copyReportTemplate('inventree_so_report.html', 'sales order report')
return super().setUp()

View File

@ -918,6 +918,24 @@ class StockTest(StockTestBase):
self.assertEqual(C21.get_ancestors().count(), 1)
self.assertEqual(C22.get_ancestors().count(), 1)
def test_metadata(self):
"""Unit tests for the metadata field."""
for model in [StockItem, StockLocation]:
p = model.objects.first()
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 StockBarcodeTest(StockTestBase):
"""Run barcode tests for the stock app"""