mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
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:
parent
4e72ac303f
commit
807784d9e4
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -14,7 +14,9 @@ from InvenTree.api import AttachmentMixin, APIDownloadMixin, ListCreateDestroyAP
|
||||
from InvenTree.helpers import str2bool, isNull, DownloadFile
|
||||
from InvenTree.filters import InvenTreeOrderingFilter
|
||||
from InvenTree.status_codes import BuildStatus
|
||||
from InvenTree.mixins import CreateAPI, RetrieveUpdateDestroyAPI, ListCreateAPI
|
||||
from InvenTree.mixins import CreateAPI, RetrieveUpdateAPI, RetrieveUpdateDestroyAPI, ListCreateAPI
|
||||
|
||||
from plugin.serializers import MetadataSerializer
|
||||
|
||||
import build.admin
|
||||
import build.serializers
|
||||
@ -290,6 +292,16 @@ class BuildOrderContextMixin:
|
||||
return ctx
|
||||
|
||||
|
||||
class BuildOrderMetadata(RetrieveUpdateAPI):
|
||||
"""API endpoint for viewing / updating BuildOrder metadata."""
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Return MetadataSerializer instance"""
|
||||
return MetadataSerializer(Build, *args, **kwargs)
|
||||
|
||||
queryset = Build.objects.all()
|
||||
|
||||
|
||||
class BuildOutputCreate(BuildOrderContextMixin, CreateAPI):
|
||||
"""API endpoint for creating new build output(s)."""
|
||||
|
||||
@ -461,6 +473,16 @@ class BuildItemList(ListCreateAPI):
|
||||
]
|
||||
|
||||
|
||||
class BuildItemMetadata(RetrieveUpdateAPI):
|
||||
"""API endpoint for viewing / updating BuildItem metadata."""
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Return MetadataSerializer instance"""
|
||||
return MetadataSerializer(BuildItem, *args, **kwargs)
|
||||
|
||||
queryset = BuildItem.objects.all()
|
||||
|
||||
|
||||
class BuildAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
|
||||
"""API endpoint for listing (and creating) BuildOrderAttachment objects."""
|
||||
|
||||
@ -493,7 +515,10 @@ build_api_urls = [
|
||||
|
||||
# Build Items
|
||||
re_path(r'^item/', include([
|
||||
re_path(r'^(?P<pk>\d+)/', BuildItemDetail.as_view(), name='api-build-item-detail'),
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
re_path(r'^metadata/', BuildItemMetadata.as_view(), name='api-build-item-metadata'),
|
||||
re_path(r'^.*$', BuildItemDetail.as_view(), name='api-build-item-detail'),
|
||||
])),
|
||||
re_path(r'^.*$', BuildItemList.as_view(), name='api-build-item-list'),
|
||||
])),
|
||||
|
||||
@ -507,6 +532,7 @@ build_api_urls = [
|
||||
re_path(r'^finish/', BuildFinish.as_view(), name='api-build-finish'),
|
||||
re_path(r'^cancel/', BuildCancel.as_view(), name='api-build-cancel'),
|
||||
re_path(r'^unallocate/', BuildUnallocate.as_view(), name='api-build-unallocate'),
|
||||
re_path(r'^metadata/', BuildOrderMetadata.as_view(), name='api-build-metadata'),
|
||||
re_path(r'^.*$', BuildDetail.as_view(), name='api-build-detail'),
|
||||
])),
|
||||
|
||||
|
23
InvenTree/build/migrations/0039_auto_20230317_0816.py
Normal file
23
InvenTree/build/migrations/0039_auto_20230317_0816.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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.
|
||||
|
@ -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"""
|
||||
|
@ -195,6 +195,16 @@ class ManufacturerPartDetail(RetrieveUpdateDestroyAPI):
|
||||
serializer_class = ManufacturerPartSerializer
|
||||
|
||||
|
||||
class ManufacturerPartMetadata(RetrieveUpdateAPI):
|
||||
"""API endpoint for viewing / updating ManufacturerPart metadata."""
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Return MetadataSerializer instance for a Company"""
|
||||
return MetadataSerializer(ManufacturerPart, *args, **kwargs)
|
||||
|
||||
queryset = ManufacturerPart.objects.all()
|
||||
|
||||
|
||||
class ManufacturerPartAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
|
||||
"""API endpoint for listing (and creating) a ManufacturerPartAttachment (file upload)."""
|
||||
|
||||
@ -424,6 +434,16 @@ class SupplierPartDetail(RetrieveUpdateDestroyAPI):
|
||||
]
|
||||
|
||||
|
||||
class SupplierPartMetadata(RetrieveUpdateAPI):
|
||||
"""API endpoint for viewing / updating SupplierPart metadata."""
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Return MetadataSerializer instance for a Company"""
|
||||
return MetadataSerializer(SupplierPart, *args, **kwargs)
|
||||
|
||||
queryset = SupplierPart.objects.all()
|
||||
|
||||
|
||||
class SupplierPriceBreakFilter(rest_filters.FilterSet):
|
||||
"""Custom API filters for the SupplierPriceBreak list endpoint"""
|
||||
|
||||
@ -510,7 +530,10 @@ manufacturer_part_api_urls = [
|
||||
re_path(r'^.*$', ManufacturerPartParameterList.as_view(), name='api-manufacturer-part-parameter-list'),
|
||||
])),
|
||||
|
||||
re_path(r'^(?P<pk>\d+)/?', ManufacturerPartDetail.as_view(), name='api-manufacturer-part-detail'),
|
||||
re_path(r'^(?P<pk>\d+)/?', include([
|
||||
re_path('^metadata/', ManufacturerPartMetadata.as_view(), name='api-manufacturer-part-metadata'),
|
||||
re_path('^.*$', ManufacturerPartDetail.as_view(), name='api-manufacturer-part-detail'),
|
||||
])),
|
||||
|
||||
# Catch anything else
|
||||
re_path(r'^.*$', ManufacturerPartList.as_view(), name='api-manufacturer-part-list'),
|
||||
@ -519,7 +542,10 @@ manufacturer_part_api_urls = [
|
||||
|
||||
supplier_part_api_urls = [
|
||||
|
||||
re_path(r'^(?P<pk>\d+)/?', SupplierPartDetail.as_view(), name='api-supplier-part-detail'),
|
||||
re_path(r'^(?P<pk>\d+)/?', include([
|
||||
re_path('^metadata/', SupplierPartMetadata.as_view(), name='api-supplier-part-metadata'),
|
||||
re_path('^.*$', SupplierPartDetail.as_view(), name='api-supplier-part-detail'),
|
||||
])),
|
||||
|
||||
# Catch anything else
|
||||
re_path(r'^.*$', SupplierPartList.as_view(), name='api-supplier-part-list'),
|
||||
|
23
InvenTree/company/migrations/0055_auto_20230317_0816.py
Normal file
23
InvenTree/company/migrations/0055_auto_20230317_0816.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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:
|
||||
|
@ -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)
|
||||
|
@ -13,11 +13,13 @@ from rest_framework.exceptions import NotFound
|
||||
|
||||
import common.models
|
||||
import InvenTree.helpers
|
||||
from InvenTree.mixins import ListAPI, RetrieveAPI, RetrieveUpdateDestroyAPI
|
||||
from InvenTree.mixins import (ListAPI, RetrieveAPI, RetrieveUpdateAPI,
|
||||
RetrieveUpdateDestroyAPI)
|
||||
from InvenTree.tasks import offload_task
|
||||
from part.models import Part
|
||||
from plugin.base.label import label as plugin_label
|
||||
from plugin.registry import registry
|
||||
from plugin.serializers import MetadataSerializer
|
||||
from stock.models import StockItem, StockLocation
|
||||
|
||||
from .models import PartLabel, StockItemLabel, StockLocationLabel
|
||||
@ -305,6 +307,16 @@ class StockItemLabelDetail(StockItemLabelMixin, RetrieveUpdateDestroyAPI):
|
||||
pass
|
||||
|
||||
|
||||
class StockItemLabelMetadata(RetrieveUpdateAPI):
|
||||
"""API endpoint for viewing / updating StockItemLabel metadata."""
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Return MetadataSerializer instance for a Company"""
|
||||
return MetadataSerializer(StockItemLabel, *args, **kwargs)
|
||||
|
||||
queryset = StockItemLabel.objects.all()
|
||||
|
||||
|
||||
class StockItemLabelPrint(StockItemLabelMixin, LabelPrintMixin, RetrieveAPI):
|
||||
"""API endpoint for printing a StockItemLabel object."""
|
||||
pass
|
||||
@ -337,6 +349,16 @@ class StockLocationLabelDetail(StockLocationLabelMixin, RetrieveUpdateDestroyAPI
|
||||
pass
|
||||
|
||||
|
||||
class StockLocationLabelMetadata(RetrieveUpdateAPI):
|
||||
"""API endpoint for viewing / updating StockLocationLabel metadata."""
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Return MetadataSerializer instance for a Company"""
|
||||
return MetadataSerializer(StockLocationLabel, *args, **kwargs)
|
||||
|
||||
queryset = StockLocationLabel.objects.all()
|
||||
|
||||
|
||||
class StockLocationLabelPrint(StockLocationLabelMixin, LabelPrintMixin, RetrieveAPI):
|
||||
"""API endpoint for printing a StockLocationLabel object."""
|
||||
pass
|
||||
@ -356,6 +378,16 @@ class PartLabelList(PartLabelMixin, LabelListView):
|
||||
pass
|
||||
|
||||
|
||||
class PartLabelMetadata(RetrieveUpdateAPI):
|
||||
"""API endpoint for viewing / updating PartLabel metadata."""
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Return MetadataSerializer instance for a Company"""
|
||||
return MetadataSerializer(PartLabel, *args, **kwargs)
|
||||
|
||||
queryset = PartLabel.objects.all()
|
||||
|
||||
|
||||
class PartLabelDetail(PartLabelMixin, RetrieveUpdateDestroyAPI):
|
||||
"""API endpoint for a single PartLabel object."""
|
||||
pass
|
||||
@ -373,6 +405,7 @@ label_api_urls = [
|
||||
# Detail views
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
re_path(r'print/?', StockItemLabelPrint.as_view(), name='api-stockitem-label-print'),
|
||||
re_path(r'metadata/', StockItemLabelMetadata.as_view(), name='api-stockitem-label-metadata'),
|
||||
re_path(r'^.*$', StockItemLabelDetail.as_view(), name='api-stockitem-label-detail'),
|
||||
])),
|
||||
|
||||
@ -385,6 +418,7 @@ label_api_urls = [
|
||||
# Detail views
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
re_path(r'print/?', StockLocationLabelPrint.as_view(), name='api-stocklocation-label-print'),
|
||||
re_path(r'metadata/', StockLocationLabelMetadata.as_view(), name='api-stocklocation-label-metadata'),
|
||||
re_path(r'^.*$', StockLocationLabelDetail.as_view(), name='api-stocklocation-label-detail'),
|
||||
])),
|
||||
|
||||
@ -397,6 +431,7 @@ label_api_urls = [
|
||||
# Detail views
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
re_path(r'^print/', PartLabelPrint.as_view(), name='api-part-label-print'),
|
||||
re_path(r'^metadata/', PartLabelMetadata.as_view(), name='api-part-label-metadata'),
|
||||
re_path(r'^.*$', PartLabelDetail.as_view(), name='api-part-label-detail'),
|
||||
])),
|
||||
|
||||
|
28
InvenTree/label/migrations/0009_auto_20230317_0816.py
Normal file
28
InvenTree/label/migrations/0009_auto_20230317_0816.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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:
|
||||
|
@ -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)
|
||||
|
@ -604,6 +604,16 @@ class PurchaseOrderLineItemDetail(RetrieveUpdateDestroyAPI):
|
||||
return queryset
|
||||
|
||||
|
||||
class PurchaseOrderLineItemMetadata(RetrieveUpdateAPI):
|
||||
"""API endpoint for viewing / updating PurchaseOrderLineItem metadata."""
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Return MetadataSerializer instance for a Company"""
|
||||
return MetadataSerializer(models.PurchaseOrderLineItem, *args, **kwargs)
|
||||
|
||||
queryset = models.PurchaseOrderLineItem.objects.all()
|
||||
|
||||
|
||||
class PurchaseOrderExtraLineList(GeneralExtraLineList, ListCreateAPI):
|
||||
"""API endpoint for accessing a list of PurchaseOrderExtraLine objects."""
|
||||
|
||||
@ -627,6 +637,16 @@ class PurchaseOrderExtraLineDetail(RetrieveUpdateDestroyAPI):
|
||||
serializer_class = serializers.PurchaseOrderExtraLineSerializer
|
||||
|
||||
|
||||
class PurchaseOrderExtraLineItemMetadata(RetrieveUpdateAPI):
|
||||
"""API endpoint for viewing / updating PurchaseOrderExtraLineItem metadata."""
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Return MetadataSerializer instance"""
|
||||
return MetadataSerializer(models.PurchaseOrderExtraLine, *args, **kwargs)
|
||||
|
||||
queryset = models.PurchaseOrderExtraLine.objects.all()
|
||||
|
||||
|
||||
class SalesOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
|
||||
"""API endpoint for listing (and creating) a SalesOrderAttachment (file upload)"""
|
||||
|
||||
@ -943,6 +963,16 @@ class SalesOrderLineItemList(APIDownloadMixin, ListCreateAPI):
|
||||
]
|
||||
|
||||
|
||||
class SalesOrderLineItemMetadata(RetrieveUpdateAPI):
|
||||
"""API endpoint for viewing / updating SalesOrderLineItem metadata."""
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Return MetadataSerializer instance"""
|
||||
return MetadataSerializer(models.SalesOrderLineItem, *args, **kwargs)
|
||||
|
||||
queryset = models.SalesOrderLineItem.objects.all()
|
||||
|
||||
|
||||
class SalesOrderExtraLineList(GeneralExtraLineList, ListCreateAPI):
|
||||
"""API endpoint for accessing a list of SalesOrderExtraLine objects."""
|
||||
|
||||
@ -966,6 +996,16 @@ class SalesOrderExtraLineDetail(RetrieveUpdateDestroyAPI):
|
||||
serializer_class = serializers.SalesOrderExtraLineSerializer
|
||||
|
||||
|
||||
class SalesOrderExtraLineItemMetadata(RetrieveUpdateAPI):
|
||||
"""API endpoint for viewing / updating SalesOrderExtraLine metadata."""
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Return MetadataSerializer instance"""
|
||||
return MetadataSerializer(models.SalesOrderExtraLine, *args, **kwargs)
|
||||
|
||||
queryset = models.SalesOrderExtraLine.objects.all()
|
||||
|
||||
|
||||
class SalesOrderLineItemDetail(RetrieveUpdateDestroyAPI):
|
||||
"""API endpoint for detail view of a SalesOrderLineItem object."""
|
||||
|
||||
@ -1191,6 +1231,16 @@ class SalesOrderShipmentComplete(CreateAPI):
|
||||
return ctx
|
||||
|
||||
|
||||
class SalesOrderShipmentMetadata(RetrieveUpdateAPI):
|
||||
"""API endpoint for viewing / updating SalesOrderShipment metadata."""
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Return MetadataSerializer instance"""
|
||||
return MetadataSerializer(models.SalesOrderShipment, *args, **kwargs)
|
||||
|
||||
queryset = models.SalesOrderShipment.objects.all()
|
||||
|
||||
|
||||
class PurchaseOrderAttachmentList(AttachmentMixin, ListCreateDestroyAPIView):
|
||||
"""API endpoint for listing (and creating) a PurchaseOrderAttachment (file upload)"""
|
||||
|
||||
@ -1391,13 +1441,19 @@ order_api_urls = [
|
||||
|
||||
# API endpoints for purchase order line items
|
||||
re_path(r'^po-line/', include([
|
||||
path('<int:pk>/', PurchaseOrderLineItemDetail.as_view(), name='api-po-line-detail'),
|
||||
path('<int:pk>/', include([
|
||||
re_path(r'^metadata/', PurchaseOrderLineItemMetadata.as_view(), name='api-po-line-metadata'),
|
||||
re_path(r'^.*$', PurchaseOrderLineItemDetail.as_view(), name='api-po-line-detail'),
|
||||
])),
|
||||
re_path(r'^.*$', PurchaseOrderLineItemList.as_view(), name='api-po-line-list'),
|
||||
])),
|
||||
|
||||
# API endpoints for purchase order extra line
|
||||
re_path(r'^po-extra-line/', include([
|
||||
path('<int:pk>/', PurchaseOrderExtraLineDetail.as_view(), name='api-po-extra-line-detail'),
|
||||
path('<int:pk>/', include([
|
||||
re_path(r'^metadata/', PurchaseOrderExtraLineItemMetadata.as_view(), name='api-po-extra-line-metadata'),
|
||||
re_path(r'^.*$', PurchaseOrderExtraLineDetail.as_view(), name='api-po-extra-line-detail'),
|
||||
])),
|
||||
path('', PurchaseOrderExtraLineList.as_view(), name='api-po-extra-line-list'),
|
||||
])),
|
||||
|
||||
@ -1411,6 +1467,7 @@ order_api_urls = [
|
||||
re_path(r'^shipment/', include([
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
path('ship/', SalesOrderShipmentComplete.as_view(), name='api-so-shipment-ship'),
|
||||
re_path(r'^metadata/', SalesOrderShipmentMetadata.as_view(), name='api-so-shipment-metadata'),
|
||||
re_path(r'^.*$', SalesOrderShipmentDetail.as_view(), name='api-so-shipment-detail'),
|
||||
])),
|
||||
re_path(r'^.*$', SalesOrderShipmentList.as_view(), name='api-so-shipment-list'),
|
||||
@ -1434,13 +1491,19 @@ order_api_urls = [
|
||||
|
||||
# API endpoints for sales order line items
|
||||
re_path(r'^so-line/', include([
|
||||
path('<int:pk>/', SalesOrderLineItemDetail.as_view(), name='api-so-line-detail'),
|
||||
path('<int:pk>/', include([
|
||||
re_path(r'^metadata/', SalesOrderLineItemMetadata.as_view(), name='api-so-line-metadata'),
|
||||
re_path(r'^.*$', SalesOrderLineItemDetail.as_view(), name='api-so-line-detail'),
|
||||
])),
|
||||
path('', SalesOrderLineItemList.as_view(), name='api-so-line-list'),
|
||||
])),
|
||||
|
||||
# API endpoints for sales order extra line
|
||||
re_path(r'^so-extra-line/', include([
|
||||
path('<int:pk>/', SalesOrderExtraLineDetail.as_view(), name='api-so-extra-line-detail'),
|
||||
path('<int:pk>/', include([
|
||||
re_path(r'^metadata/', SalesOrderExtraLineItemMetadata.as_view(), name='api-so-extra-line-metadata'),
|
||||
re_path(r'^.*$', SalesOrderExtraLineDetail.as_view(), name='api-so-extra-line-detail'),
|
||||
])),
|
||||
path('', SalesOrderExtraLineList.as_view(), name='api-so-extra-line-list'),
|
||||
])),
|
||||
|
||||
|
@ -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
|
||||
|
38
InvenTree/order/migrations/0080_auto_20230317_0816.py
Normal file
38
InvenTree/order/migrations/0080_auto_20230317_0816.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -1459,6 +1459,16 @@ class PartParameterTemplateDetail(RetrieveUpdateDestroyAPI):
|
||||
serializer_class = part_serializers.PartParameterTemplateSerializer
|
||||
|
||||
|
||||
class PartParameterTemplateMetadata(RetrieveUpdateAPI):
|
||||
"""API endpoint for viewing / updating PartParameterTemplate metadata."""
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Return a MetadataSerializer pointing to the referenced PartParameterTemplate instance"""
|
||||
return MetadataSerializer(PartParameterTemplate, *args, **kwargs)
|
||||
|
||||
queryset = PartParameterTemplate.objects.all()
|
||||
|
||||
|
||||
class PartParameterList(ListCreateAPI):
|
||||
"""API endpoint for accessing a list of PartParameter objects.
|
||||
|
||||
@ -1926,6 +1936,16 @@ class BomItemSubstituteDetail(RetrieveUpdateDestroyAPI):
|
||||
serializer_class = part_serializers.BomItemSubstituteSerializer
|
||||
|
||||
|
||||
class BomItemMetadata(RetrieveUpdateAPI):
|
||||
"""API endpoint for viewing / updating PartBOM metadata."""
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Return a MetadataSerializer pointing to the referenced PartCategory instance"""
|
||||
return MetadataSerializer(BomItem, *args, **kwargs)
|
||||
|
||||
queryset = BomItem.objects.all()
|
||||
|
||||
|
||||
part_api_urls = [
|
||||
|
||||
# Base URL for PartCategory API endpoints
|
||||
@ -1933,8 +1953,8 @@ part_api_urls = [
|
||||
re_path(r'^tree/', CategoryTree.as_view(), name='api-part-category-tree'),
|
||||
|
||||
re_path(r'^parameters/', include([
|
||||
re_path('^(?P<pk>\d+)/', CategoryParameterDetail.as_view(), name='api-part-category-parameter-detail'),
|
||||
re_path('^.*$', CategoryParameterList.as_view(), name='api-part-category-parameter-list'),
|
||||
re_path(r'^(?P<pk>\d+)/', CategoryParameterDetail.as_view(), name='api-part-category-parameter-detail'),
|
||||
re_path(r'^.*$', CategoryParameterList.as_view(), name='api-part-category-parameter-list'),
|
||||
])),
|
||||
|
||||
# Category detail endpoints
|
||||
@ -1982,7 +2002,10 @@ part_api_urls = [
|
||||
# Base URL for PartParameter API endpoints
|
||||
re_path(r'^parameter/', include([
|
||||
path('template/', include([
|
||||
re_path(r'^(?P<pk>\d+)/', PartParameterTemplateDetail.as_view(), name='api-part-parameter-template-detail'),
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
re_path(r'^metadata/?', PartParameterTemplateMetadata.as_view(), name='api-part-parameter-template-metadata'),
|
||||
re_path(r'^.*$', PartParameterTemplateDetail.as_view(), name='api-part-parameter-template-detail'),
|
||||
])),
|
||||
re_path(r'^.*$', PartParameterTemplateList.as_view(), name='api-part-parameter-template-list'),
|
||||
])),
|
||||
|
||||
@ -2059,6 +2082,7 @@ bom_api_urls = [
|
||||
# BOM Item Detail
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
re_path(r'^validate/?', BomItemValidate.as_view(), name='api-bom-item-validate'),
|
||||
re_path(r'^metadata/?', BomItemMetadata.as_view(), name='api-bom-item-metadata'),
|
||||
re_path(r'^.*$', BomDetail.as_view(), name='api-bom-item-detail'),
|
||||
])),
|
||||
|
||||
|
23
InvenTree/part/migrations/0103_auto_20230317_0816.py
Normal file
23
InvenTree/part/migrations/0103_auto_20230317_0816.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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"""
|
||||
|
@ -274,8 +274,9 @@ class PartTest(TestCase):
|
||||
self.assertEqual(float(self.r1.get_internal_price(10)), 0.5)
|
||||
|
||||
def test_metadata(self):
|
||||
"""Unit tests for the Part metadata field."""
|
||||
p = Part.objects.get(pk=1)
|
||||
"""Unit tests for the metadata field."""
|
||||
for model in [Part]:
|
||||
p = model.objects.first()
|
||||
self.assertIsNone(p.metadata)
|
||||
|
||||
self.assertIsNone(p.get_metadata('test'))
|
||||
|
@ -18,7 +18,9 @@ import common.models
|
||||
import InvenTree.helpers
|
||||
import order.models
|
||||
import part.models
|
||||
from InvenTree.mixins import ListAPI, RetrieveAPI, RetrieveUpdateDestroyAPI
|
||||
from InvenTree.mixins import (ListAPI, RetrieveAPI, RetrieveUpdateAPI,
|
||||
RetrieveUpdateDestroyAPI)
|
||||
from plugin.serializers import MetadataSerializer
|
||||
from stock.models import StockItem, StockItemAttachment
|
||||
|
||||
from .models import (BillOfMaterialsReport, BuildReport, PurchaseOrderReport,
|
||||
@ -421,6 +423,31 @@ class SalesOrderReportPrint(SalesOrderReportMixin, ReportPrintMixin, RetrieveAPI
|
||||
pass
|
||||
|
||||
|
||||
class ReportMetadata(RetrieveUpdateAPI):
|
||||
"""API endpoint for viewing / updating Report metadata."""
|
||||
MODEL_REF = 'reportmodel'
|
||||
|
||||
def _get_model(self, *args, **kwargs):
|
||||
"""Return model depending on which report type is requested in get_view constructor."""
|
||||
reportmodel = self.kwargs.get(self.MODEL_REF, PurchaseOrderReport)
|
||||
|
||||
if reportmodel not in [PurchaseOrderReport, SalesOrderReport, BuildReport, BillOfMaterialsReport, TestReport]:
|
||||
raise ValidationError("Invalid report model")
|
||||
return reportmodel
|
||||
|
||||
# Return corresponding Serializer
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Return correct MetadataSerializer instance depending on which model is requested"""
|
||||
# Get type of report, make sure its one of the allowed values
|
||||
UseModel = self._get_model(*args, **kwargs)
|
||||
return MetadataSerializer(UseModel, *args, **kwargs)
|
||||
|
||||
def get_queryset(self, *args, **kwargs):
|
||||
"""Return correct queryset depending on which model is requested"""
|
||||
UseModel = self._get_model(*args, **kwargs)
|
||||
return UseModel.objects.all()
|
||||
|
||||
|
||||
report_api_urls = [
|
||||
|
||||
# Purchase order reports
|
||||
@ -428,6 +455,7 @@ report_api_urls = [
|
||||
# Detail views
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
re_path(r'print/', PurchaseOrderReportPrint.as_view(), name='api-po-report-print'),
|
||||
re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: PurchaseOrderReport}, name='api-po-report-metadata'),
|
||||
path('', PurchaseOrderReportDetail.as_view(), name='api-po-report-detail'),
|
||||
])),
|
||||
|
||||
@ -440,6 +468,7 @@ report_api_urls = [
|
||||
# Detail views
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
re_path(r'print/', SalesOrderReportPrint.as_view(), name='api-so-report-print'),
|
||||
re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: SalesOrderReport}, name='api-so-report-metadata'),
|
||||
path('', SalesOrderReportDetail.as_view(), name='api-so-report-detail'),
|
||||
])),
|
||||
|
||||
@ -451,6 +480,7 @@ report_api_urls = [
|
||||
# Detail views
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
re_path(r'print/?', BuildReportPrint.as_view(), name='api-build-report-print'),
|
||||
re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: BuildReport}, name='api-build-report-metadata'),
|
||||
re_path(r'^.$', BuildReportDetail.as_view(), name='api-build-report-detail'),
|
||||
])),
|
||||
|
||||
@ -464,6 +494,7 @@ report_api_urls = [
|
||||
# Detail views
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
re_path(r'print/?', BOMReportPrint.as_view(), name='api-bom-report-print'),
|
||||
re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: BillOfMaterialsReport}, name='api-bom-report-metadata'),
|
||||
re_path(r'^.*$', BOMReportDetail.as_view(), name='api-bom-report-detail'),
|
||||
])),
|
||||
|
||||
@ -476,6 +507,7 @@ report_api_urls = [
|
||||
# Detail views
|
||||
re_path(r'^(?P<pk>\d+)/', include([
|
||||
re_path(r'print/?', StockItemTestReportPrint.as_view(), name='api-stockitem-testreport-print'),
|
||||
re_path(r'metadata/', ReportMetadata.as_view(), {ReportMetadata.MODEL_REF: TestReport}, name='api-stockitem-testreport-metadata'),
|
||||
re_path(r'^.*$', StockItemTestReportDetail.as_view(), name='api-stockitem-testreport-detail'),
|
||||
])),
|
||||
|
||||
|
38
InvenTree/report/migrations/0017_auto_20230317_0816.py
Normal file
38
InvenTree/report/migrations/0017_auto_20230317_0816.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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"""
|
||||
|
Loading…
Reference in New Issue
Block a user