From 807784d9e48f20cc552fdd3a95ca5aec9bf54d30 Mon Sep 17 00:00:00 2001 From: miggland Date: Thu, 23 Mar 2023 11:51:08 +0100 Subject: [PATCH] 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 --- .pre-commit-config.yaml | 2 +- InvenTree/InvenTree/api_version.py | 5 +- InvenTree/build/api.py | 30 +++++++- .../migrations/0039_auto_20230317_0816.py | 23 ++++++ InvenTree/build/models.py | 5 +- InvenTree/build/test_build.py | 23 ++++++ InvenTree/company/api.py | 30 +++++++- .../migrations/0055_auto_20230317_0816.py | 23 ++++++ InvenTree/company/models.py | 4 +- InvenTree/company/tests.py | 35 +++++++++ InvenTree/label/api.py | 37 +++++++++- .../migrations/0009_auto_20230317_0816.py | 28 ++++++++ InvenTree/label/models.py | 3 +- InvenTree/label/tests.py | 18 +++++ InvenTree/order/api.py | 71 +++++++++++++++++-- InvenTree/order/fixtures/order.yaml | 8 +++ .../migrations/0080_auto_20230317_0816.py | 38 ++++++++++ InvenTree/order/models.py | 4 +- InvenTree/order/test_sales_order.py | 25 ++++++- InvenTree/order/tests.py | 21 +++++- InvenTree/part/admin.py | 2 +- InvenTree/part/api.py | 30 +++++++- .../migrations/0103_auto_20230317_0816.py | 23 ++++++ InvenTree/part/models.py | 4 +- InvenTree/part/test_bom_item.py | 18 +++++ InvenTree/part/test_param.py | 18 +++++ InvenTree/part/test_part.py | 23 +++--- InvenTree/report/api.py | 34 ++++++++- .../migrations/0017_auto_20230317_0816.py | 38 ++++++++++ InvenTree/report/models.py | 3 +- InvenTree/report/tests.py | 38 +++++++++- InvenTree/stock/tests.py | 18 +++++ 32 files changed, 642 insertions(+), 40 deletions(-) create mode 100644 InvenTree/build/migrations/0039_auto_20230317_0816.py create mode 100644 InvenTree/company/migrations/0055_auto_20230317_0816.py create mode 100644 InvenTree/label/migrations/0009_auto_20230317_0816.py create mode 100644 InvenTree/order/migrations/0080_auto_20230317_0816.py create mode 100644 InvenTree/part/migrations/0103_auto_20230317_0816.py create mode 100644 InvenTree/report/migrations/0017_auto_20230317_0816.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0c259e9978..553a649749 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index 78f0fb15e5..19e3ba5e64 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/InvenTree/build/api.py b/InvenTree/build/api.py index dd52145ad0..f0fd607370 100644 --- a/InvenTree/build/api.py +++ b/InvenTree/build/api.py @@ -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\d+)/', BuildItemDetail.as_view(), name='api-build-item-detail'), + re_path(r'^(?P\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'), ])), diff --git a/InvenTree/build/migrations/0039_auto_20230317_0816.py b/InvenTree/build/migrations/0039_auto_20230317_0816.py new file mode 100644 index 0000000000..d62273d278 --- /dev/null +++ b/InvenTree/build/migrations/0039_auto_20230317_0816.py @@ -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'), + ), + ] diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py index 091d656d9e..48509edfdd 100644 --- a/InvenTree/build/models.py +++ b/InvenTree/build/models.py @@ -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. diff --git a/InvenTree/build/test_build.py b/InvenTree/build/test_build.py index 8c293bf5d6..d6788d38ec 100644 --- a/InvenTree/build/test_build.py +++ b/InvenTree/build/test_build.py @@ -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""" diff --git a/InvenTree/company/api.py b/InvenTree/company/api.py index 8479348ce3..e45a977b9e 100644 --- a/InvenTree/company/api.py +++ b/InvenTree/company/api.py @@ -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\d+)/?', ManufacturerPartDetail.as_view(), name='api-manufacturer-part-detail'), + re_path(r'^(?P\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\d+)/?', SupplierPartDetail.as_view(), name='api-supplier-part-detail'), + re_path(r'^(?P\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'), diff --git a/InvenTree/company/migrations/0055_auto_20230317_0816.py b/InvenTree/company/migrations/0055_auto_20230317_0816.py new file mode 100644 index 0000000000..a4a85a7f35 --- /dev/null +++ b/InvenTree/company/migrations/0055_auto_20230317_0816.py @@ -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'), + ), + ] diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index b6af76a022..57c3541f66 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -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: diff --git a/InvenTree/company/tests.py b/InvenTree/company/tests.py index ae0ab87e5d..4c83864297 100644 --- a/InvenTree/company/tests.py +++ b/InvenTree/company/tests.py @@ -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) diff --git a/InvenTree/label/api.py b/InvenTree/label/api.py index f07a8f891c..25d672225a 100644 --- a/InvenTree/label/api.py +++ b/InvenTree/label/api.py @@ -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\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\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\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'), ])), diff --git a/InvenTree/label/migrations/0009_auto_20230317_0816.py b/InvenTree/label/migrations/0009_auto_20230317_0816.py new file mode 100644 index 0000000000..16b81a5f7f --- /dev/null +++ b/InvenTree/label/migrations/0009_auto_20230317_0816.py @@ -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'), + ), + ] diff --git a/InvenTree/label/models.py b/InvenTree/label/models.py index 27a340f0c6..a862860436 100644 --- a/InvenTree/label/models.py +++ b/InvenTree/label/models.py @@ -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: diff --git a/InvenTree/label/tests.py b/InvenTree/label/tests.py index 88e4fad4f0..523364ff1f 100644 --- a/InvenTree/label/tests.py +++ b/InvenTree/label/tests.py @@ -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) diff --git a/InvenTree/order/api.py b/InvenTree/order/api.py index b4a77fa34d..34f318aa1e 100644 --- a/InvenTree/order/api.py +++ b/InvenTree/order/api.py @@ -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('/', PurchaseOrderLineItemDetail.as_view(), name='api-po-line-detail'), + path('/', 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('/', PurchaseOrderExtraLineDetail.as_view(), name='api-po-extra-line-detail'), + path('/', 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\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('/', SalesOrderLineItemDetail.as_view(), name='api-so-line-detail'), + path('/', 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('/', SalesOrderExtraLineDetail.as_view(), name='api-so-extra-line-detail'), + path('/', 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'), ])), diff --git a/InvenTree/order/fixtures/order.yaml b/InvenTree/order/fixtures/order.yaml index fc0dc070fc..abf0958b85 100644 --- a/InvenTree/order/fixtures/order.yaml +++ b/InvenTree/order/fixtures/order.yaml @@ -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 diff --git a/InvenTree/order/migrations/0080_auto_20230317_0816.py b/InvenTree/order/migrations/0080_auto_20230317_0816.py new file mode 100644 index 0000000000..1ea1c4c95e --- /dev/null +++ b/InvenTree/order/migrations/0080_auto_20230317_0816.py @@ -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'), + ), + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 00e2639aea..82be199475 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -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 diff --git a/InvenTree/order/test_sales_order.py b/InvenTree/order/test_sales_order.py index 9050ad8704..2a42abc08c 100644 --- a/InvenTree/order/test_sales_order.py +++ b/InvenTree/order/test_sales_order.py @@ -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) diff --git a/InvenTree/order/tests.py b/InvenTree/order/tests.py index c2557a0707..b0ed86418b 100644 --- a/InvenTree/order/tests.py +++ b/InvenTree/order/tests.py @@ -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) diff --git a/InvenTree/part/admin.py b/InvenTree/part/admin.py index f4b77d7303..885da4da6c 100644 --- a/InvenTree/part/admin.py +++ b/InvenTree/part/admin.py @@ -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 diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 74f49fd0a7..055ac98a82 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -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\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\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\d+)/', PartParameterTemplateDetail.as_view(), name='api-part-parameter-template-detail'), + re_path(r'^(?P\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\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'), ])), diff --git a/InvenTree/part/migrations/0103_auto_20230317_0816.py b/InvenTree/part/migrations/0103_auto_20230317_0816.py new file mode 100644 index 0000000000..4259c9b520 --- /dev/null +++ b/InvenTree/part/migrations/0103_auto_20230317_0816.py @@ -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'), + ), + ] diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 4acf4daf9b..9d103b0bcf 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -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 diff --git a/InvenTree/part/test_bom_item.py b/InvenTree/part/test_bom_item.py index 196915e593..5148ea7048 100644 --- a/InvenTree/part/test_bom_item.py +++ b/InvenTree/part/test_bom_item.py @@ -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) diff --git a/InvenTree/part/test_param.py b/InvenTree/part/test_param.py index 3c4c8e8644..8b8906e0ea 100644 --- a/InvenTree/part/test_param.py +++ b/InvenTree/part/test_param.py @@ -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""" diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index aaeb4d9085..d502b659f8 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -274,21 +274,22 @@ 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) - self.assertIsNone(p.metadata) + """Unit tests for the metadata field.""" + for model in [Part]: + p = model.objects.first() + self.assertIsNone(p.metadata) - self.assertIsNone(p.get_metadata('test')) - self.assertEqual(p.get_metadata('test', backup_value=123), 123) + 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) + # 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) + for k in ['apple', 'banana', 'carrot', 'carrot', 'banana']: + p.set_metadata(k, k) - self.assertEqual(len(p.metadata.keys()), 4) + self.assertEqual(len(p.metadata.keys()), 4) def test_related(self): """Unit tests for the PartRelated model""" diff --git a/InvenTree/report/api.py b/InvenTree/report/api.py index 8fb7182e3a..f4b9ac335c 100644 --- a/InvenTree/report/api.py +++ b/InvenTree/report/api.py @@ -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\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\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\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\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\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'), ])), diff --git a/InvenTree/report/migrations/0017_auto_20230317_0816.py b/InvenTree/report/migrations/0017_auto_20230317_0816.py new file mode 100644 index 0000000000..27088a566c --- /dev/null +++ b/InvenTree/report/migrations/0017_auto_20230317_0816.py @@ -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'), + ), + ] diff --git a/InvenTree/report/models.py b/InvenTree/report/models.py index 668fe0509c..4e645c1c6d 100644 --- a/InvenTree/report/models.py +++ b/InvenTree/report/models.py @@ -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 diff --git a/InvenTree/report/tests.py b/InvenTree/report/tests.py index b9cb8c1632..27327c1d22 100644 --- a/InvenTree/report/tests.py +++ b/InvenTree/report/tests.py @@ -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() diff --git a/InvenTree/stock/tests.py b/InvenTree/stock/tests.py index e4c4914dd6..bb3fe10ce8 100644 --- a/InvenTree/stock/tests.py +++ b/InvenTree/stock/tests.py @@ -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"""