Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2022-05-19 01:42:22 +10:00
commit 83207d5c71
13 changed files with 352 additions and 19 deletions

View File

@ -4,11 +4,14 @@ InvenTree API version information
# InvenTree API version
INVENTREE_API_VERSION = 49
INVENTREE_API_VERSION = 50
"""
Increment this API version number whenever there is a significant change to the API that any clients need to know about
v50 -> 2022-05-18 : https://github.com/inventree/InvenTree/pull/2912
- Implement Attachments for manufacturer parts
v49 -> 2022-05-09 : https://github.com/inventree/InvenTree/pull/2957
- Allows filtering of plugin list by 'active' status
- Allows filtering of plugin list by 'mixin' support

View File

@ -8,7 +8,7 @@ import import_export.widgets as widgets
from .models import Company
from .models import SupplierPart
from .models import SupplierPriceBreak
from .models import ManufacturerPart, ManufacturerPartParameter
from .models import ManufacturerPart, ManufacturerPartAttachment, ManufacturerPartParameter
from part.models import Part
@ -109,6 +109,16 @@ class ManufacturerPartAdmin(ImportExportModelAdmin):
autocomplete_fields = ('part', 'manufacturer',)
class ManufacturerPartAttachmentAdmin(ImportExportModelAdmin):
"""
Admin class for ManufacturerPartAttachment model
"""
list_display = ('manufacturer_part', 'attachment', 'comment')
autocomplete_fields = ('manufacturer_part',)
class ManufacturerPartParameterResource(ModelResource):
"""
Class for managing ManufacturerPartParameter data import/export
@ -175,4 +185,5 @@ admin.site.register(SupplierPart, SupplierPartAdmin)
admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin)
admin.site.register(ManufacturerPart, ManufacturerPartAdmin)
admin.site.register(ManufacturerPartAttachment, ManufacturerPartAttachmentAdmin)
admin.site.register(ManufacturerPartParameter, ManufacturerPartParameterAdmin)

View File

@ -12,13 +12,14 @@ from django.urls import include, re_path
from django.db.models import Q
from InvenTree.helpers import str2bool
from InvenTree.api import AttachmentMixin
from .models import Company
from .models import ManufacturerPart, ManufacturerPartParameter
from .models import ManufacturerPart, ManufacturerPartAttachment, ManufacturerPartParameter
from .models import SupplierPart, SupplierPriceBreak
from .serializers import CompanySerializer
from .serializers import ManufacturerPartSerializer, ManufacturerPartParameterSerializer
from .serializers import ManufacturerPartSerializer, ManufacturerPartAttachmentSerializer, ManufacturerPartParameterSerializer
from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer
@ -160,6 +161,32 @@ class ManufacturerPartDetail(generics.RetrieveUpdateDestroyAPIView):
serializer_class = ManufacturerPartSerializer
class ManufacturerPartAttachmentList(AttachmentMixin, generics.ListCreateAPIView):
"""
API endpoint for listing (and creating) a ManufacturerPartAttachment (file upload).
"""
queryset = ManufacturerPartAttachment.objects.all()
serializer_class = ManufacturerPartAttachmentSerializer
filter_backends = [
DjangoFilterBackend,
]
filter_fields = [
'manufacturer_part',
]
class ManufacturerPartAttachmentDetail(AttachmentMixin, generics.RetrieveUpdateDestroyAPIView):
"""
Detail endpooint for ManufacturerPartAttachment model
"""
queryset = ManufacturerPartAttachment.objects.all()
serializer_class = ManufacturerPartAttachmentSerializer
class ManufacturerPartParameterList(generics.ListCreateAPIView):
"""
API endpoint for list view of ManufacturerPartParamater model.
@ -387,6 +414,12 @@ class SupplierPriceBreakDetail(generics.RetrieveUpdateDestroyAPIView):
manufacturer_part_api_urls = [
# Base URL for ManufacturerPartAttachment API endpoints
re_path(r'^attachment/', include([
re_path(r'^(?P<pk>\d+)/', ManufacturerPartAttachmentDetail.as_view(), name='api-manufacturer-part-attachment-detail'),
re_path(r'^$', ManufacturerPartAttachmentList.as_view(), name='api-manufacturer-part-attachment-list'),
])),
re_path(r'^parameter/', include([
re_path(r'^(?P<pk>\d+)/', ManufacturerPartParameterDetail.as_view(), name='api-manufacturer-part-parameter-detail'),

View File

@ -0,0 +1,33 @@
# Generated by Django 3.2.13 on 2022-05-01 12:57
import InvenTree.fields
import InvenTree.models
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('company', '0042_supplierpricebreak_updated'),
]
operations = [
migrations.CreateModel(
name='ManufacturerPartAttachment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attachment', models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment')),
('link', InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link')),
('comment', models.CharField(blank=True, help_text='File comment', max_length=100, verbose_name='Comment')),
('upload_date', models.DateField(auto_now_add=True, null=True, verbose_name='upload date')),
('manufacturer_part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='company.manufacturerpart', verbose_name='Manufacturer Part')),
('user', models.ForeignKey(blank=True, help_text='User', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'abstract': False,
},
),
]

View File

@ -22,6 +22,7 @@ from stdimage.models import StdImageField
from InvenTree.helpers import getMediaUrl, getBlankImage, getBlankThumbnail
from InvenTree.fields import InvenTreeURLField
from InvenTree.models import InvenTreeAttachment
from InvenTree.status_codes import PurchaseOrderStatus
import InvenTree.validators
@ -380,6 +381,22 @@ class ManufacturerPart(models.Model):
return s
class ManufacturerPartAttachment(InvenTreeAttachment):
"""
Model for storing file attachments against a ManufacturerPart object
"""
@staticmethod
def get_api_url():
return reverse('api-manufacturer-part-attachment-list')
def getSubdir(self):
return os.path.join("manufacturer_part_files", str(self.manufacturer_part.id))
manufacturer_part = models.ForeignKey(ManufacturerPart, on_delete=models.CASCADE,
verbose_name=_('Manufacturer Part'), related_name='attachments')
class ManufacturerPartParameter(models.Model):
"""
A ManufacturerPartParameter represents a key:value parameter for a MnaufacturerPart.

View File

@ -8,6 +8,7 @@ from rest_framework import serializers
from sql_util.utils import SubqueryCount
from InvenTree.serializers import InvenTreeAttachmentSerializer
from InvenTree.serializers import InvenTreeDecimalField
from InvenTree.serializers import InvenTreeImageSerializerField
from InvenTree.serializers import InvenTreeModelSerializer
@ -16,7 +17,7 @@ from InvenTree.serializers import InvenTreeMoneySerializer
from part.serializers import PartBriefSerializer
from .models import Company
from .models import ManufacturerPart, ManufacturerPartParameter
from .models import ManufacturerPart, ManufacturerPartAttachment, ManufacturerPartParameter
from .models import SupplierPart, SupplierPriceBreak
from common.settings import currency_code_default, currency_code_mappings
@ -142,6 +143,29 @@ class ManufacturerPartSerializer(InvenTreeModelSerializer):
]
class ManufacturerPartAttachmentSerializer(InvenTreeAttachmentSerializer):
"""
Serializer for the ManufacturerPartAttachment class
"""
class Meta:
model = ManufacturerPartAttachment
fields = [
'pk',
'manufacturer_part',
'attachment',
'filename',
'link',
'comment',
'upload_date',
]
read_only_fields = [
'upload_date',
]
class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
"""
Serializer for the ManufacturerPartParameter model

View File

@ -144,6 +144,21 @@ src="{% static 'img/blank_image.png' %}"
</div>
</div>
<div class='panel panel-hidden' id='panel-attachments'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
<h4>{% trans "Attachments" %}</h4>
{% include "spacer.html" %}
<div class='btn-group' role='group'>
{% include "attachment_button.html" %}
</div>
</div>
</div>
<div class='panel-content'>
{% include "attachment_table.html" %}
</div>
</div>
<div class='panel panel-hidden' id='panel-parameters'>
<div class='panel-heading'>
<div class='d-flex flex-wrap'>
@ -178,6 +193,34 @@ src="{% static 'img/blank_image.png' %}"
{% block js_ready %}
{{ block.super }}
onPanelLoad("attachments", function() {
loadAttachmentTable('{% url "api-manufacturer-part-attachment-list" %}', {
filters: {
manufacturer_part: {{ part.pk }},
},
fields: {
manufacturer_part: {
value: {{ part.pk }},
hidden: true
}
}
});
enableDragAndDrop(
'#attachment-dropzone',
'{% url "api-manufacturer-part-attachment-list" %}',
{
data: {
manufacturer_part: {{ part.id }},
},
label: 'attachment',
success: function(data, status, xhr) {
reloadAttachmentTable();
}
}
);
});
function reloadParameters() {
$("#parameter-table").bootstrapTable("refresh");
}

View File

@ -4,5 +4,7 @@
{% trans "Parameters" as text %}
{% include "sidebar_item.html" with label='parameters' text=text icon="fa-th-list" %}
{% trans "Attachments" as text %}
{% include "sidebar_item.html" with label='attachments' text=text icon="fa-paperclip" %}
{% trans "Supplier Parts" as text %}
{% include "sidebar_item.html" with label='supplier-parts' text=text icon="fa-building" %}

View File

@ -13,6 +13,7 @@ import InvenTree.helpers
from plugin.helpers import MixinImplementationError, MixinNotImplementedError, render_template
from plugin.models import PluginConfig, PluginSetting
from plugin.registry import registry
from plugin.urls import PLUGIN_BASE
@ -204,7 +205,7 @@ class ScheduleMixin:
Schedule.objects.create(
name=task_name,
func='plugin.registry.call_function',
func=registry.call_plugin_function,
args=f"'{slug}', '{func_name}'",
schedule_type=task['schedule'],
minutes=task.get('minutes', None),

View File

@ -7,7 +7,7 @@ from rest_framework.views import APIView
from InvenTree.tasks import offload_task
from plugin import registry
from plugin.registry import registry
from stock.models import StockItem, StockLocation
@ -40,9 +40,6 @@ class LocatePluginView(APIView):
# StockLocation to identify
location_pk = request.data.get('location', None)
if not item_pk and not location_pk:
raise ParseError("Must supply either 'item' or 'location' parameter")
data = {
"success": "Identification plugin activated",
"plugin": plugin,
@ -53,27 +50,27 @@ class LocatePluginView(APIView):
try:
StockItem.objects.get(pk=item_pk)
offload_task(registry.call_function, plugin, 'locate_stock_item', item_pk)
offload_task(registry.call_plugin_function, plugin, 'locate_stock_item', item_pk)
data['item'] = item_pk
return Response(data)
except StockItem.DoesNotExist:
raise NotFound("StockItem matching PK '{item}' not found")
except (ValueError, StockItem.DoesNotExist):
raise NotFound(f"StockItem matching PK '{item_pk}' not found")
elif location_pk:
try:
StockLocation.objects.get(pk=location_pk)
offload_task(registry.call_function, plugin, 'locate_stock_location', location_pk)
offload_task(registry.call_plugin_function, plugin, 'locate_stock_location', location_pk)
data['location'] = location_pk
return Response(data)
except StockLocation.DoesNotExist:
raise NotFound("StockLocation matching PK {'location'} not found")
except (ValueError, StockLocation.DoesNotExist):
raise NotFound(f"StockLocation matching PK '{location_pk}' not found")
else:
raise NotFound()
raise ParseError("Must supply either 'item' or 'location' parameter")

View File

@ -0,0 +1,148 @@
"""
Unit tests for the 'locate' plugin mixin class
"""
from django.urls import reverse
from InvenTree.api_tester import InvenTreeAPITestCase
from plugin.registry import registry
from stock.models import StockItem, StockLocation
class LocatePluginTests(InvenTreeAPITestCase):
fixtures = [
'category',
'part',
'location',
'stock',
]
def test_installed(self):
"""Test that a locate plugin is actually installed"""
plugins = registry.with_mixin('locate')
self.assertTrue(len(plugins) > 0)
self.assertTrue('samplelocate' in [p.slug for p in plugins])
def test_locate_fail(self):
"""Test various API failure modes"""
url = reverse('api-locate-plugin')
# Post without a plugin
response = self.post(
url,
{},
expected_code=400
)
self.assertIn("'plugin' field must be supplied", str(response.data))
# Post with a plugin that does not exist, or is invalid
for slug in ['xyz', 'event', 'plugin']:
response = self.post(
url,
{
'plugin': slug,
},
expected_code=400,
)
self.assertIn(f"Plugin '{slug}' is not installed, or does not support the location mixin", str(response.data))
# Post with a valid plugin, but no other data
response = self.post(
url,
{
'plugin': 'samplelocate',
},
expected_code=400
)
self.assertIn("Must supply either 'item' or 'location' parameter", str(response.data))
# Post with valid plugin, invalid item or location
for pk in ['qq', 99999, -42]:
response = self.post(
url,
{
'plugin': 'samplelocate',
'item': pk,
},
expected_code=404
)
self.assertIn(f"StockItem matching PK '{pk}' not found", str(response.data))
response = self.post(
url,
{
'plugin': 'samplelocate',
'location': pk,
},
expected_code=404,
)
self.assertIn(f"StockLocation matching PK '{pk}' not found", str(response.data))
def test_locate_item(self):
"""
Test that the plugin correctly 'locates' a StockItem
As the background worker is not running during unit testing,
the sample 'locate' function will be called 'inline'
"""
url = reverse('api-locate-plugin')
item = StockItem.objects.get(pk=1)
# The sample plugin will set the 'located' metadata tag
item.set_metadata('located', False)
response = self.post(
url,
{
'plugin': 'samplelocate',
'item': 1,
},
expected_code=200
)
self.assertEqual(response.data['item'], 1)
item.refresh_from_db()
# Item metadata should have been altered!
self.assertTrue(item.metadata['located'])
def test_locate_location(self):
"""
Test that the plugin correctly 'locates' a StockLocation
"""
url = reverse('api-locate-plugin')
for location in StockLocation.objects.all():
location.set_metadata('located', False)
response = self.post(
url,
{
'plugin': 'samplelocate',
'location': location.pk,
},
expected_code=200
)
self.assertEqual(response.data['location'], location.pk)
location.refresh_from_db()
# Item metadata should have been altered!
self.assertTrue(location.metadata['located'])

View File

@ -23,7 +23,23 @@ class SampleLocatePlugin(LocateMixin, InvenTreePlugin):
SLUG = "samplelocate"
TITLE = "Sample plugin for locating items"
VERSION = "0.1"
VERSION = "0.2"
def locate_stock_item(self, item_pk):
from stock.models import StockItem
logger.info(f"SampleLocatePlugin attempting to locate item ID {item_pk}")
try:
item = StockItem.objects.get(pk=item_pk)
logger.info(f"StockItem {item_pk} located!")
# Tag metadata
item.set_metadata('located', True)
except (ValueError, StockItem.DoesNotExist):
logger.error(f"StockItem ID {item_pk} does not exist!")
def locate_stock_location(self, location_pk):
@ -34,5 +50,9 @@ class SampleLocatePlugin(LocateMixin, InvenTreePlugin):
try:
location = StockLocation.objects.get(pk=location_pk)
logger.info(f"Location exists at '{location.pathstring}'")
except StockLocation.DoesNotExist:
# Tag metadata
location.set_metadata('located', True)
except (ValueError, StockLocation.DoesNotExist):
logger.error(f"Location ID {location_pk} does not exist!")

View File

@ -101,6 +101,7 @@ class RuleSet(models.Model):
'company_supplierpart',
'company_manufacturerpart',
'company_manufacturerpartparameter',
'company_manufacturerpartattachment',
'label_partlabel',
],
'stock_location': [