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: hooks:
- id: isort - id: isort
- repo: https://github.com/jazzband/pip-tools - repo: https://github.com/jazzband/pip-tools
rev: 6.12.2 rev: 6.12.3
hooks: hooks:
- id: pip-compile - id: pip-compile
name: pip-compile requirements-dev.in name: pip-compile requirements-dev.in

View File

@ -2,11 +2,14 @@
# InvenTree API version # 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 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 v102 -> 2023-03-18 : https://github.com/inventree/InvenTree/pull/4505
- Adds global search API endpoint for consolidated search results - 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.helpers import str2bool, isNull, DownloadFile
from InvenTree.filters import InvenTreeOrderingFilter from InvenTree.filters import InvenTreeOrderingFilter
from InvenTree.status_codes import BuildStatus 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.admin
import build.serializers import build.serializers
@ -290,6 +292,16 @@ class BuildOrderContextMixin:
return ctx 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): class BuildOutputCreate(BuildOrderContextMixin, CreateAPI):
"""API endpoint for creating new build output(s).""" """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): class BuildAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing (and creating) BuildOrderAttachment objects.""" """API endpoint for listing (and creating) BuildOrderAttachment objects."""
@ -493,7 +515,10 @@ build_api_urls = [
# Build Items # Build Items
re_path(r'^item/', include([ 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'), 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'^finish/', BuildFinish.as_view(), name='api-build-finish'),
re_path(r'^cancel/', BuildCancel.as_view(), name='api-build-cancel'), 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'^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'), 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 import InvenTree.tasks
from plugin.events import trigger_event from plugin.events import trigger_event
from plugin.models import MetadataMixin
import common.notifications import common.notifications
from part import models as PartModels from part import models as PartModels
@ -40,7 +41,7 @@ from stock import models as StockModels
from users import models as UserModels 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. """A Build object organises the creation of new StockItem objects from other existing StockItem objects.
Attributes: Attributes:
@ -1140,7 +1141,7 @@ class BuildOrderAttachment(InvenTreeAttachment):
build = models.ForeignKey(Build, on_delete=models.CASCADE, related_name='attachments') 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. """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. 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()) 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): class AutoAllocationTests(BuildTestBase):
"""Tests for auto allocating stock against a build order""" """Tests for auto allocating stock against a build order"""

View File

@ -195,6 +195,16 @@ class ManufacturerPartDetail(RetrieveUpdateDestroyAPI):
serializer_class = ManufacturerPartSerializer 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): class ManufacturerPartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing (and creating) a ManufacturerPartAttachment (file upload).""" """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): class SupplierPriceBreakFilter(rest_filters.FilterSet):
"""Custom API filters for the SupplierPriceBreak list endpoint""" """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'^.*$', 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 # Catch anything else
re_path(r'^.*$', ManufacturerPartList.as_view(), name='api-manufacturer-part-list'), re_path(r'^.*$', ManufacturerPartList.as_view(), name='api-manufacturer-part-list'),
@ -519,7 +542,10 @@ manufacturer_part_api_urls = [
supplier_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 # Catch anything else
re_path(r'^.*$', SupplierPartList.as_view(), name='api-supplier-part-list'), 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) 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. """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: 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. """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: Attributes:

View File

@ -132,6 +132,23 @@ class CompanySimpleTest(TestCase):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
company.full_clean() 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): class ContactSimpleTest(TestCase):
"""Unit tests for the Contact model""" """Unit tests for the Contact model"""
@ -205,3 +222,21 @@ class ManufacturerPartSimpleTest(TestCase):
Part.objects.get(pk=self.part.id).delete() Part.objects.get(pk=self.part.id).delete()
# Check that ManufacturerPart was deleted # Check that ManufacturerPart was deleted
self.assertEqual(ManufacturerPart.objects.count(), 3) 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 common.models
import InvenTree.helpers 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 InvenTree.tasks import offload_task
from part.models import Part from part.models import Part
from plugin.base.label import label as plugin_label from plugin.base.label import label as plugin_label
from plugin.registry import registry from plugin.registry import registry
from plugin.serializers import MetadataSerializer
from stock.models import StockItem, StockLocation from stock.models import StockItem, StockLocation
from .models import PartLabel, StockItemLabel, StockLocationLabel from .models import PartLabel, StockItemLabel, StockLocationLabel
@ -305,6 +307,16 @@ class StockItemLabelDetail(StockItemLabelMixin, RetrieveUpdateDestroyAPI):
pass 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): class StockItemLabelPrint(StockItemLabelMixin, LabelPrintMixin, RetrieveAPI):
"""API endpoint for printing a StockItemLabel object.""" """API endpoint for printing a StockItemLabel object."""
pass pass
@ -337,6 +349,16 @@ class StockLocationLabelDetail(StockLocationLabelMixin, RetrieveUpdateDestroyAPI
pass 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): class StockLocationLabelPrint(StockLocationLabelMixin, LabelPrintMixin, RetrieveAPI):
"""API endpoint for printing a StockLocationLabel object.""" """API endpoint for printing a StockLocationLabel object."""
pass pass
@ -356,6 +378,16 @@ class PartLabelList(PartLabelMixin, LabelListView):
pass 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): class PartLabelDetail(PartLabelMixin, RetrieveUpdateDestroyAPI):
"""API endpoint for a single PartLabel object.""" """API endpoint for a single PartLabel object."""
pass pass
@ -373,6 +405,7 @@ label_api_urls = [
# Detail views # Detail views
re_path(r'^(?P<pk>\d+)/', include([ re_path(r'^(?P<pk>\d+)/', include([
re_path(r'print/?', StockItemLabelPrint.as_view(), name='api-stockitem-label-print'), 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'), re_path(r'^.*$', StockItemLabelDetail.as_view(), name='api-stockitem-label-detail'),
])), ])),
@ -385,6 +418,7 @@ label_api_urls = [
# Detail views # Detail views
re_path(r'^(?P<pk>\d+)/', include([ re_path(r'^(?P<pk>\d+)/', include([
re_path(r'print/?', StockLocationLabelPrint.as_view(), name='api-stocklocation-label-print'), 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'), re_path(r'^.*$', StockLocationLabelDetail.as_view(), name='api-stocklocation-label-detail'),
])), ])),
@ -397,6 +431,7 @@ label_api_urls = [
# Detail views # Detail views
re_path(r'^(?P<pk>\d+)/', include([ re_path(r'^(?P<pk>\d+)/', include([
re_path(r'^print/', PartLabelPrint.as_view(), name='api-part-label-print'), 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'), 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 part.models
import stock.models import stock.models
from InvenTree.helpers import normalize, validateFilterString from InvenTree.helpers import normalize, validateFilterString
from plugin.models import MetadataMixin
try: try:
from django_weasyprint import WeasyTemplateResponseMixin from django_weasyprint import WeasyTemplateResponseMixin
@ -70,7 +71,7 @@ class WeasyprintLabelMixin(WeasyTemplateResponseMixin):
self.pdf_filename = kwargs.get('filename', 'label.pdf') self.pdf_filename = kwargs.get('filename', 'label.pdf')
class LabelTemplate(models.Model): class LabelTemplate(MetadataMixin, models.Model):
"""Base class for generic, filterable labels.""" """Base class for generic, filterable labels."""
class Meta: class Meta:

View File

@ -130,3 +130,21 @@ class LabelTest(InvenTreeAPITestCase):
self.assertIn("http://testserver/part/1/", content) self.assertIn("http://testserver/part/1/", content)
self.assertIn("image: /static/img/blank_image.png", content) self.assertIn("image: /static/img/blank_image.png", content)
self.assertIn("logo: /static/img/inventree.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 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): class PurchaseOrderExtraLineList(GeneralExtraLineList, ListCreateAPI):
"""API endpoint for accessing a list of PurchaseOrderExtraLine objects.""" """API endpoint for accessing a list of PurchaseOrderExtraLine objects."""
@ -627,6 +637,16 @@ class PurchaseOrderExtraLineDetail(RetrieveUpdateDestroyAPI):
serializer_class = serializers.PurchaseOrderExtraLineSerializer 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): class SalesOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing (and creating) a SalesOrderAttachment (file upload)""" """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): class SalesOrderExtraLineList(GeneralExtraLineList, ListCreateAPI):
"""API endpoint for accessing a list of SalesOrderExtraLine objects.""" """API endpoint for accessing a list of SalesOrderExtraLine objects."""
@ -966,6 +996,16 @@ class SalesOrderExtraLineDetail(RetrieveUpdateDestroyAPI):
serializer_class = serializers.SalesOrderExtraLineSerializer 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): class SalesOrderLineItemDetail(RetrieveUpdateDestroyAPI):
"""API endpoint for detail view of a SalesOrderLineItem object.""" """API endpoint for detail view of a SalesOrderLineItem object."""
@ -1191,6 +1231,16 @@ class SalesOrderShipmentComplete(CreateAPI):
return ctx 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): class PurchaseOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
"""API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload)""" """API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload)"""
@ -1391,13 +1441,19 @@ order_api_urls = [
# API endpoints for purchase order line items # API endpoints for purchase order line items
re_path(r'^po-line/', include([ 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'), re_path(r'^.*$', PurchaseOrderLineItemList.as_view(), name='api-po-line-list'),
])), ])),
# API endpoints for purchase order extra line # API endpoints for purchase order extra line
re_path(r'^po-extra-line/', include([ 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'), 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'^shipment/', include([
re_path(r'^(?P<pk>\d+)/', include([ re_path(r'^(?P<pk>\d+)/', include([
path('ship/', SalesOrderShipmentComplete.as_view(), name='api-so-shipment-ship'), 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'^.*$', SalesOrderShipmentDetail.as_view(), name='api-so-shipment-detail'),
])), ])),
re_path(r'^.*$', SalesOrderShipmentList.as_view(), name='api-so-shipment-list'), 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 # API endpoints for sales order line items
re_path(r'^so-line/', include([ 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'), path('', SalesOrderLineItemList.as_view(), name='api-so-line-list'),
])), ])),
# API endpoints for sales order extra line # API endpoints for sales order extra line
re_path(r'^so-extra-line/', include([ 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'), path('', SalesOrderExtraLineList.as_view(), name='api-so-extra-line-list'),
])), ])),

View File

@ -110,3 +110,11 @@
order: 1 order: 1
part: 5 part: 5
quantity: 1 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') 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. """Abstract model for an order line item.
Attributes: Attributes:
@ -1256,7 +1256,7 @@ class SalesOrderLineItem(OrderLineItem):
return self.shipped >= self.quantity 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. """The SalesOrderShipment model represents a physical shipment made against a SalesOrder.
- Points to a single SalesOrder object - Points to a single SalesOrder object

View File

@ -11,7 +11,8 @@ import order.tasks
from common.models import InvenTreeSetting, NotificationMessage from common.models import InvenTreeSetting, NotificationMessage
from company.models import Company from company.models import Company
from InvenTree import status_codes as status from InvenTree import status_codes as status
from order.models import (SalesOrder, SalesOrderAllocation, SalesOrderLineItem, from order.models import (SalesOrder, SalesOrderAllocation,
SalesOrderExtraLine, SalesOrderLineItem,
SalesOrderShipment) SalesOrderShipment)
from part.models import Part from part.models import Part
from stock.models import StockItem from stock.models import StockItem
@ -54,6 +55,9 @@ class SalesOrderTest(TestCase):
# Create a line item # Create a line item
cls.line = SalesOrderLineItem.objects.create(quantity=50, order=cls.order, part=cls.part) 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): def test_so_reference(self):
"""Unit tests for sales order generation""" """Unit tests for sales order generation"""
@ -293,3 +297,22 @@ class SalesOrderTest(TestCase):
# However *no* notification should have been generated for the creating user # However *no* notification should have been generated for the creating user
self.assertFalse(messages.filter(user__pk=3).exists()) 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 stock.models import StockItem, StockLocation
from users.models import Owner from users.models import Owner
from .models import PurchaseOrder, PurchaseOrderLineItem from .models import (PurchaseOrder, PurchaseOrderExtraLine,
PurchaseOrderLineItem)
class OrderTest(TestCase): class OrderTest(TestCase):
@ -384,3 +385,21 @@ class OrderTest(TestCase):
# However *no* notification should have been generated for the creating user # However *no* notification should have been generated for the creating user
self.assertFalse(messages.filter(user__pk=3).exists()) 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) is_importing = getattr(self, 'is_importing', False)
include_pricing = getattr(self, 'include_pricing', False) include_pricing = getattr(self, 'include_pricing', False)
to_remove = [] to_remove = ['metadata']
if is_importing or not include_pricing: if is_importing or not include_pricing:
# Remove pricing fields in this instance # Remove pricing fields in this instance

View File

@ -1459,6 +1459,16 @@ class PartParameterTemplateDetail(RetrieveUpdateDestroyAPI):
serializer_class = part_serializers.PartParameterTemplateSerializer 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): class PartParameterList(ListCreateAPI):
"""API endpoint for accessing a list of PartParameter objects. """API endpoint for accessing a list of PartParameter objects.
@ -1926,6 +1936,16 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI):
serializer_class = part_serializers.BomItemSubstituteSerializer 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 = [ part_api_urls = [
# Base URL for PartCategory API endpoints # 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'^tree/', CategoryTree.as_view(), name='api-part-category-tree'),
re_path(r'^parameters/', include([ re_path(r'^parameters/', include([
re_path('^(?P<pk>\d+)/', CategoryParameterDetail.as_view(), name='api-part-category-parameter-detail'), re_path(r'^(?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'^.*$', CategoryParameterList.as_view(), name='api-part-category-parameter-list'),
])), ])),
# Category detail endpoints # Category detail endpoints
@ -1982,7 +2002,10 @@ part_api_urls = [
# Base URL for PartParameter API endpoints # Base URL for PartParameter API endpoints
re_path(r'^parameter/', include([ re_path(r'^parameter/', include([
path('template/', 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'), re_path(r'^.*$', PartParameterTemplateList.as_view(), name='api-part-parameter-template-list'),
])), ])),
@ -2059,6 +2082,7 @@ bom_api_urls = [
# BOM Item Detail # BOM Item Detail
re_path(r'^(?P<pk>\d+)/', include([ re_path(r'^(?P<pk>\d+)/', include([
re_path(r'^validate/?', BomItemValidate.as_view(), name='api-bom-item-validate'), 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'), 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.""" """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. """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. 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')) 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 BomItem links a part to its component items.
A part can have a BOM (bill of materials) which defines 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) 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.full_clean()
t3.save() # pragma: no cover 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): class TestCategoryTemplates(TransactionTestCase):
"""Test class for PartCategoryParameterTemplate model""" """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) self.assertEqual(float(self.r1.get_internal_price(10)), 0.5)
def test_metadata(self): def test_metadata(self):
"""Unit tests for the Part metadata field.""" """Unit tests for the metadata field."""
p = Part.objects.get(pk=1) for model in [Part]:
p = model.objects.first()
self.assertIsNone(p.metadata) self.assertIsNone(p.metadata)
self.assertIsNone(p.get_metadata('test')) self.assertIsNone(p.get_metadata('test'))

View File

@ -18,7 +18,9 @@ import common.models
import InvenTree.helpers import InvenTree.helpers
import order.models import order.models
import part.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 stock.models import StockItem, StockItemAttachment
from .models import (BillOfMaterialsReport, BuildReport, PurchaseOrderReport, from .models import (BillOfMaterialsReport, BuildReport, PurchaseOrderReport,
@ -421,6 +423,31 @@ class SalesOrderReportPrint(SalesOrderReportMixin, ReportPrintMixin, RetrieveAPI
pass 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 = [ report_api_urls = [
# Purchase order reports # Purchase order reports
@ -428,6 +455,7 @@ report_api_urls = [
# Detail views # Detail views
re_path(r'^(?P<pk>\d+)/', include([ re_path(r'^(?P<pk>\d+)/', include([
re_path(r'print/', PurchaseOrderReportPrint.as_view(), name='api-po-report-print'), 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'), path('', PurchaseOrderReportDetail.as_view(), name='api-po-report-detail'),
])), ])),
@ -440,6 +468,7 @@ report_api_urls = [
# Detail views # Detail views
re_path(r'^(?P<pk>\d+)/', include([ re_path(r'^(?P<pk>\d+)/', include([
re_path(r'print/', SalesOrderReportPrint.as_view(), name='api-so-report-print'), 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'), path('', SalesOrderReportDetail.as_view(), name='api-so-report-detail'),
])), ])),
@ -451,6 +480,7 @@ report_api_urls = [
# Detail views # Detail views
re_path(r'^(?P<pk>\d+)/', include([ re_path(r'^(?P<pk>\d+)/', include([
re_path(r'print/?', BuildReportPrint.as_view(), name='api-build-report-print'), 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'), re_path(r'^.$', BuildReportDetail.as_view(), name='api-build-report-detail'),
])), ])),
@ -464,6 +494,7 @@ report_api_urls = [
# Detail views # Detail views
re_path(r'^(?P<pk>\d+)/', include([ re_path(r'^(?P<pk>\d+)/', include([
re_path(r'print/?', BOMReportPrint.as_view(), name='api-bom-report-print'), 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'), re_path(r'^.*$', BOMReportDetail.as_view(), name='api-bom-report-detail'),
])), ])),
@ -476,6 +507,7 @@ report_api_urls = [
# Detail views # Detail views
re_path(r'^(?P<pk>\d+)/', include([ re_path(r'^(?P<pk>\d+)/', include([
re_path(r'print/?', StockItemTestReportPrint.as_view(), name='api-stockitem-testreport-print'), 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'), 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 part.models
import stock.models import stock.models
from InvenTree.helpers import validateFilterString from InvenTree.helpers import validateFilterString
from plugin.models import MetadataMixin
try: try:
from django_weasyprint import WeasyTemplateResponseMixin from django_weasyprint import WeasyTemplateResponseMixin
@ -174,7 +175,7 @@ class ReportBase(models.Model):
) )
class ReportTemplateBase(ReportBase): class ReportTemplateBase(MetadataMixin, ReportBase):
"""Reporting template model. """Reporting template model.
Able to be passed context data Able to be passed context data

View File

@ -258,7 +258,6 @@ class ReportTest(InvenTreeAPITestCase):
reports = self.model.objects.all() reports = self.model.objects.all()
n = len(reports) n = len(reports)
# API endpoint must return correct number of reports # API endpoint must return correct number of reports
self.assertEqual(len(response.data), n) self.assertEqual(len(response.data), n)
@ -281,6 +280,25 @@ class ReportTest(InvenTreeAPITestCase):
response = self.get(url, {'enabled': False}) response = self.get(url, {'enabled': False})
self.assertEqual(len(response.data), n) 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): class TestReportTest(ReportTest):
"""Unit testing class for the stock item TestReport model""" """Unit testing class for the stock item TestReport model"""
@ -393,6 +411,12 @@ class BOMReportTest(ReportTest):
detail_url = 'api-bom-report-detail' detail_url = 'api-bom-report-detail'
print_url = 'api-bom-report-print' 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): class PurchaseOrderReportTest(ReportTest):
"""Unit test class fort he PurchaseOrderReport model""" """Unit test class fort he PurchaseOrderReport model"""
@ -402,6 +426,12 @@ class PurchaseOrderReportTest(ReportTest):
detail_url = 'api-po-report-detail' detail_url = 'api-po-report-detail'
print_url = 'api-po-report-print' 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): class SalesOrderReportTest(ReportTest):
"""Unit test class for the SalesOrderReport model""" """Unit test class for the SalesOrderReport model"""
@ -410,3 +440,9 @@ class SalesOrderReportTest(ReportTest):
list_url = 'api-so-report-list' list_url = 'api-so-report-list'
detail_url = 'api-so-report-detail' detail_url = 'api-so-report-detail'
print_url = 'api-so-report-print' 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(C21.get_ancestors().count(), 1)
self.assertEqual(C22.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): class StockBarcodeTest(StockTestBase):
"""Run barcode tests for the stock app""" """Run barcode tests for the stock app"""