mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge branch 'inventree:master' into automatic-shipment-creation
This commit is contained in:
commit
5ece98ed39
12
.github/workflows/docker_latest.yaml
vendored
12
.github/workflows/docker_latest.yaml
vendored
@ -18,6 +18,18 @@ jobs:
|
||||
- name: Check version number
|
||||
run: |
|
||||
python3 ci/check_version_number.py --dev
|
||||
- name: Build Docker Image
|
||||
run: |
|
||||
cd docker
|
||||
docker-compose build
|
||||
docker-compose run inventree-dev-server invoke update
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
cd docker
|
||||
docker-compose up -d
|
||||
docker-compose run inventree-dev-server invoke wait
|
||||
docker-compose run inventree-dev-server invoke test
|
||||
docker-compose down
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
- name: Set up Docker Buildx
|
||||
|
37
.github/workflows/docker_test.yaml
vendored
37
.github/workflows/docker_test.yaml
vendored
@ -1,37 +0,0 @@
|
||||
# Test that the InvenTree docker image compiles correctly
|
||||
|
||||
# This CI action runs on pushes to either the master or stable branches
|
||||
|
||||
# 1. Build the development docker image (as per the documentation)
|
||||
# 2. Install requied python libs into the docker container
|
||||
# 3. Launch the container
|
||||
# 4. Check that the API endpoint is available
|
||||
|
||||
name: Docker Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'stable'
|
||||
|
||||
jobs:
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v2
|
||||
- name: Build Docker Image
|
||||
run: |
|
||||
cd docker
|
||||
docker-compose -f docker-compose.sqlite.yml build
|
||||
docker-compose -f docker-compose.sqlite.yml run inventree-dev-server invoke update
|
||||
docker-compose -f docker-compose.sqlite.yml up -d
|
||||
- name: Sleepy Time
|
||||
run: sleep 60
|
||||
- name: Test API
|
||||
run: |
|
||||
pip install requests
|
||||
python3 ci/check_api_endpoint.py
|
@ -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
|
||||
|
@ -1,5 +1,7 @@
|
||||
import json
|
||||
from test.support import EnvironmentVarGuard
|
||||
import os
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
import django.core.exceptions as django_exceptions
|
||||
@ -449,17 +451,20 @@ class TestSettings(TestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.user_mdl = get_user_model()
|
||||
self.env = EnvironmentVarGuard()
|
||||
|
||||
# Create a user for auth
|
||||
user = get_user_model()
|
||||
self.user = user.objects.create_superuser('testuser1', 'test1@testing.com', 'password1')
|
||||
self.client.login(username='testuser1', password='password1')
|
||||
|
||||
def run_reload(self):
|
||||
def in_env_context(self, envs={}):
|
||||
"""Patch the env to include the given dict"""
|
||||
return mock.patch.dict(os.environ, envs)
|
||||
|
||||
def run_reload(self, envs={}):
|
||||
from plugin import registry
|
||||
|
||||
with self.env:
|
||||
with self.in_env_context(envs):
|
||||
settings.USER_ADDED = False
|
||||
registry.reload_plugins()
|
||||
|
||||
@ -475,25 +480,28 @@ class TestSettings(TestCase):
|
||||
self.assertEqual(user_count(), 1)
|
||||
|
||||
# not enough set
|
||||
self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username
|
||||
self.run_reload()
|
||||
self.run_reload({
|
||||
'INVENTREE_ADMIN_USER': 'admin'
|
||||
})
|
||||
self.assertEqual(user_count(), 1)
|
||||
|
||||
# enough set
|
||||
self.env.set('INVENTREE_ADMIN_USER', 'admin') # set username
|
||||
self.env.set('INVENTREE_ADMIN_EMAIL', 'info@example.com') # set email
|
||||
self.env.set('INVENTREE_ADMIN_PASSWORD', 'password123') # set password
|
||||
self.run_reload()
|
||||
self.run_reload({
|
||||
'INVENTREE_ADMIN_USER': 'admin', # set username
|
||||
'INVENTREE_ADMIN_EMAIL': 'info@example.com', # set email
|
||||
'INVENTREE_ADMIN_PASSWORD': 'password123' # set password
|
||||
})
|
||||
self.assertEqual(user_count(), 2)
|
||||
|
||||
# create user manually
|
||||
self.user_mdl.objects.create_user('testuser', 'test@testing.com', 'password')
|
||||
self.assertEqual(user_count(), 3)
|
||||
# check it will not be created again
|
||||
self.env.set('INVENTREE_ADMIN_USER', 'testuser')
|
||||
self.env.set('INVENTREE_ADMIN_EMAIL', 'test@testing.com')
|
||||
self.env.set('INVENTREE_ADMIN_PASSWORD', 'password')
|
||||
self.run_reload()
|
||||
self.run_reload({
|
||||
'INVENTREE_ADMIN_USER': 'testuser',
|
||||
'INVENTREE_ADMIN_EMAIL': 'test@testing.com',
|
||||
'INVENTREE_ADMIN_PASSWORD': 'password',
|
||||
})
|
||||
self.assertEqual(user_count(), 3)
|
||||
|
||||
# make sure to clean up
|
||||
@ -517,20 +525,30 @@ class TestSettings(TestCase):
|
||||
|
||||
def test_helpers_cfg_file(self):
|
||||
# normal run - not configured
|
||||
self.assertIn('InvenTree/InvenTree/config.yaml', config.get_config_file())
|
||||
|
||||
valid = [
|
||||
'inventree/config.yaml',
|
||||
'inventree/dev/config.yaml',
|
||||
]
|
||||
|
||||
self.assertTrue(any([opt in config.get_config_file().lower() for opt in valid]))
|
||||
|
||||
# with env set
|
||||
with self.env:
|
||||
self.env.set('INVENTREE_CONFIG_FILE', 'my_special_conf.yaml')
|
||||
self.assertIn('InvenTree/InvenTree/my_special_conf.yaml', config.get_config_file())
|
||||
with self.in_env_context({'INVENTREE_CONFIG_FILE': 'my_special_conf.yaml'}):
|
||||
self.assertIn('inventree/inventree/my_special_conf.yaml', config.get_config_file().lower())
|
||||
|
||||
def test_helpers_plugin_file(self):
|
||||
# normal run - not configured
|
||||
self.assertIn('InvenTree/InvenTree/plugins.txt', config.get_plugin_file())
|
||||
|
||||
valid = [
|
||||
'inventree/plugins.txt',
|
||||
'inventree/dev/plugins.txt',
|
||||
]
|
||||
|
||||
self.assertTrue(any([opt in config.get_plugin_file().lower() for opt in valid]))
|
||||
|
||||
# with env set
|
||||
with self.env:
|
||||
self.env.set('INVENTREE_PLUGIN_FILE', 'my_special_plugins.txt')
|
||||
with self.in_env_context({'INVENTREE_PLUGIN_FILE': 'my_special_plugins.txt'}):
|
||||
self.assertIn('my_special_plugins.txt', config.get_plugin_file())
|
||||
|
||||
def test_helpers_setting(self):
|
||||
@ -539,8 +557,7 @@ class TestSettings(TestCase):
|
||||
self.assertEqual(config.get_setting(TEST_ENV_NAME, None, '123!'), '123!')
|
||||
|
||||
# with env set
|
||||
with self.env:
|
||||
self.env.set(TEST_ENV_NAME, '321')
|
||||
with self.in_env_context({TEST_ENV_NAME: '321'}):
|
||||
self.assertEqual(config.get_setting(TEST_ENV_NAME, None), '321')
|
||||
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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'),
|
||||
|
||||
|
@ -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,
|
||||
},
|
||||
),
|
||||
]
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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");
|
||||
}
|
||||
|
@ -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" %}
|
@ -1046,24 +1046,29 @@ class PartDetailTests(InvenTreeAPITestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn('Upload a valid image', str(response.data))
|
||||
|
||||
# Now try to upload a valid image file
|
||||
img = PIL.Image.new('RGB', (128, 128), color='red')
|
||||
img.save('dummy_image.jpg')
|
||||
# Now try to upload a valid image file, in multiple formats
|
||||
for fmt in ['jpg', 'png', 'bmp', 'webp']:
|
||||
fn = f'dummy_image.{fmt}'
|
||||
|
||||
with open('dummy_image.jpg', 'rb') as dummy_image:
|
||||
response = upload_client.patch(
|
||||
url,
|
||||
{
|
||||
'image': dummy_image,
|
||||
},
|
||||
format='multipart',
|
||||
)
|
||||
img = PIL.Image.new('RGB', (128, 128), color='red')
|
||||
img.save(fn)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
with open(fn, 'rb') as dummy_image:
|
||||
response = upload_client.patch(
|
||||
url,
|
||||
{
|
||||
'image': dummy_image,
|
||||
},
|
||||
format='multipart',
|
||||
)
|
||||
|
||||
# And now check that the image has been set
|
||||
p = Part.objects.get(pk=pk)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# And now check that the image has been set
|
||||
p = Part.objects.get(pk=pk)
|
||||
self.assertIsNotNone(p.image)
|
||||
|
||||
def test_details(self):
|
||||
"""
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
from allauth.account.models import EmailAddress
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from django.test import TestCase
|
||||
@ -64,11 +65,21 @@ class TemplateTagTest(TestCase):
|
||||
|
||||
def test_hash(self):
|
||||
result_hash = inventree_extras.inventree_commit_hash()
|
||||
self.assertGreater(len(result_hash), 5)
|
||||
if settings.DOCKER:
|
||||
# Testing inside docker environment *may* return an empty git commit hash
|
||||
# In such a case, skip this check
|
||||
pass
|
||||
else:
|
||||
self.assertGreater(len(result_hash), 5)
|
||||
|
||||
def test_date(self):
|
||||
d = inventree_extras.inventree_commit_date()
|
||||
self.assertEqual(len(d.split('-')), 3)
|
||||
if settings.DOCKER:
|
||||
# Testing inside docker environment *may* return an empty git commit hash
|
||||
# In such a case, skip this check
|
||||
pass
|
||||
else:
|
||||
self.assertEqual(len(d.split('-')), 3)
|
||||
|
||||
def test_github(self):
|
||||
self.assertIn('github.com', inventree_extras.inventree_github_url())
|
||||
|
@ -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),
|
||||
|
@ -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")
|
||||
|
148
InvenTree/plugin/base/locate/test_locate.py
Normal file
148
InvenTree/plugin/base/locate/test_locate.py
Normal 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'])
|
@ -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!")
|
||||
|
@ -1,5 +1,10 @@
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
from django.conf import settings
|
||||
from django.views.debug import ExceptionReporter
|
||||
|
||||
from error_report.models import Error
|
||||
|
||||
from plugin.registry import registry
|
||||
|
||||
@ -21,7 +26,21 @@ class InvenTreePluginViewMixin:
|
||||
panels = []
|
||||
|
||||
for plug in registry.with_mixin('panel'):
|
||||
panels += plug.render_panels(self, self.request, ctx)
|
||||
|
||||
try:
|
||||
panels += plug.render_panels(self, self.request, ctx)
|
||||
except Exception:
|
||||
# Prevent any plugin error from crashing the page render
|
||||
kind, info, data = sys.exc_info()
|
||||
|
||||
# Log the error to the database
|
||||
Error.objects.create(
|
||||
kind=kind.__name__,
|
||||
info=info,
|
||||
data='\n'.join(traceback.format_exception(kind, info, data)),
|
||||
path=self.request.path,
|
||||
html=ExceptionReporter(self.request, kind, info, data).get_traceback_html(),
|
||||
)
|
||||
|
||||
return panels
|
||||
|
||||
|
@ -101,6 +101,7 @@ class RuleSet(models.Model):
|
||||
'company_supplierpart',
|
||||
'company_manufacturerpart',
|
||||
'company_manufacturerpartparameter',
|
||||
'company_manufacturerpartattachment',
|
||||
'label_partlabel',
|
||||
],
|
||||
'stock_location': [
|
||||
|
@ -1,4 +1,4 @@
|
||||
FROM alpine:3.13 as base
|
||||
FROM alpine:3.14 as base
|
||||
|
||||
# GitHub source
|
||||
ARG repository="https://github.com/inventree/InvenTree.git"
|
||||
@ -62,13 +62,13 @@ RUN apk -U upgrade
|
||||
RUN apk add --no-cache git make bash \
|
||||
gcc libgcc g++ libstdc++ \
|
||||
gnupg \
|
||||
libjpeg-turbo libjpeg-turbo-dev jpeg jpeg-dev \
|
||||
libjpeg-turbo libjpeg-turbo-dev jpeg jpeg-dev libwebp-dev \
|
||||
libffi libffi-dev \
|
||||
zlib zlib-dev \
|
||||
# Special deps for WeasyPrint (these will be deprecated once WeasyPrint drops cairo requirement)
|
||||
cairo cairo-dev pango pango-dev gdk-pixbuf \
|
||||
# Fonts
|
||||
fontconfig ttf-droid ttf-liberation ttf-dejavu ttf-opensans ttf-ubuntu-font-family font-croscore font-noto \
|
||||
fontconfig ttf-droid ttf-liberation ttf-dejavu ttf-opensans font-croscore font-noto \
|
||||
# Core python
|
||||
python3 python3-dev py3-pip \
|
||||
# SQLite support
|
||||
|
@ -39,7 +39,7 @@ inventree # Install the latest version of the Inve
|
||||
isort==5.10.1 # DEV: python import sorting
|
||||
markdown==3.3.4 # Force particular version of markdown
|
||||
pep8-naming==0.11.1 # PEP naming convention extension
|
||||
pillow==9.0.1 # Image manipulation
|
||||
pillow==9.1.0 # Image manipulation
|
||||
py-moneyed==0.8.0 # Specific version requirement for py-moneyed
|
||||
pygments==2.7.4 # Syntax highlighting
|
||||
python-barcode[images]==0.13.1 # Barcode generator
|
||||
|
Loading…
Reference in New Issue
Block a user