diff --git a/.github/workflows/docker_latest.yaml b/.github/workflows/docker_latest.yaml index 6b248fe0b9..74b5eb966c 100644 --- a/.github/workflows/docker_latest.yaml +++ b/.github/workflows/docker_latest.yaml @@ -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 diff --git a/.github/workflows/docker_test.yaml b/.github/workflows/docker_test.yaml deleted file mode 100644 index d96621ee66..0000000000 --- a/.github/workflows/docker_test.yaml +++ /dev/null @@ -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 diff --git a/InvenTree/InvenTree/api_version.py b/InvenTree/InvenTree/api_version.py index d2eab15468..e44aedf10b 100644 --- a/InvenTree/InvenTree/api_version.py +++ b/InvenTree/InvenTree/api_version.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 diff --git a/InvenTree/InvenTree/tests.py b/InvenTree/InvenTree/tests.py index 8b2f4c133a..501eed0834 100644 --- a/InvenTree/InvenTree/tests.py +++ b/InvenTree/InvenTree/tests.py @@ -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') diff --git a/InvenTree/company/admin.py b/InvenTree/company/admin.py index cc672f9ee5..ce5f5945b6 100644 --- a/InvenTree/company/admin.py +++ b/InvenTree/company/admin.py @@ -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) diff --git a/InvenTree/company/api.py b/InvenTree/company/api.py index 146a45f648..22c5c4b207 100644 --- a/InvenTree/company/api.py +++ b/InvenTree/company/api.py @@ -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\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\d+)/', ManufacturerPartParameterDetail.as_view(), name='api-manufacturer-part-parameter-detail'), diff --git a/InvenTree/company/migrations/0043_manufacturerpartattachment.py b/InvenTree/company/migrations/0043_manufacturerpartattachment.py new file mode 100644 index 0000000000..fe526992b0 --- /dev/null +++ b/InvenTree/company/migrations/0043_manufacturerpartattachment.py @@ -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, + }, + ), + ] diff --git a/InvenTree/company/models.py b/InvenTree/company/models.py index 40afa6db6d..e33580abff 100644 --- a/InvenTree/company/models.py +++ b/InvenTree/company/models.py @@ -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. diff --git a/InvenTree/company/serializers.py b/InvenTree/company/serializers.py index 236dcc15db..54eeb2c191 100644 --- a/InvenTree/company/serializers.py +++ b/InvenTree/company/serializers.py @@ -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 diff --git a/InvenTree/company/templates/company/manufacturer_part.html b/InvenTree/company/templates/company/manufacturer_part.html index 5a0e741c1a..a51ea45099 100644 --- a/InvenTree/company/templates/company/manufacturer_part.html +++ b/InvenTree/company/templates/company/manufacturer_part.html @@ -144,6 +144,21 @@ src="{% static 'img/blank_image.png' %}" +
+
+
+

{% trans "Attachments" %}

+ {% include "spacer.html" %} +
+ {% include "attachment_button.html" %} +
+
+
+
+ {% include "attachment_table.html" %} +
+
+
@@ -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"); } diff --git a/InvenTree/company/templates/company/manufacturer_part_sidebar.html b/InvenTree/company/templates/company/manufacturer_part_sidebar.html index bd613f76aa..04f3a39a5b 100644 --- a/InvenTree/company/templates/company/manufacturer_part_sidebar.html +++ b/InvenTree/company/templates/company/manufacturer_part_sidebar.html @@ -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" %} \ No newline at end of file diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 28df4503c9..98e545ee66 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -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): """ diff --git a/InvenTree/part/test_part.py b/InvenTree/part/test_part.py index 09e8f97c64..2df3c10b01 100644 --- a/InvenTree/part/test_part.py +++ b/InvenTree/part/test_part.py @@ -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()) diff --git a/InvenTree/plugin/base/integration/mixins.py b/InvenTree/plugin/base/integration/mixins.py index 86e3092e4f..64de5df22b 100644 --- a/InvenTree/plugin/base/integration/mixins.py +++ b/InvenTree/plugin/base/integration/mixins.py @@ -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), diff --git a/InvenTree/plugin/base/locate/api.py b/InvenTree/plugin/base/locate/api.py index a6776f2d40..3004abb262 100644 --- a/InvenTree/plugin/base/locate/api.py +++ b/InvenTree/plugin/base/locate/api.py @@ -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") diff --git a/InvenTree/plugin/base/locate/test_locate.py b/InvenTree/plugin/base/locate/test_locate.py new file mode 100644 index 0000000000..e145c2360b --- /dev/null +++ b/InvenTree/plugin/base/locate/test_locate.py @@ -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']) diff --git a/InvenTree/plugin/samples/locate/locate_sample.py b/InvenTree/plugin/samples/locate/locate_sample.py index 458b84cfa5..32a2dd713c 100644 --- a/InvenTree/plugin/samples/locate/locate_sample.py +++ b/InvenTree/plugin/samples/locate/locate_sample.py @@ -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!") diff --git a/InvenTree/plugin/views.py b/InvenTree/plugin/views.py index ee855094cc..8d28872695 100644 --- a/InvenTree/plugin/views.py +++ b/InvenTree/plugin/views.py @@ -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 diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index 7ed689f4a9..3c33614b97 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -101,6 +101,7 @@ class RuleSet(models.Model): 'company_supplierpart', 'company_manufacturerpart', 'company_manufacturerpartparameter', + 'company_manufacturerpartattachment', 'label_partlabel', ], 'stock_location': [ diff --git a/docker/Dockerfile b/docker/Dockerfile index cefd2c2b61..1b7c16db30 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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 diff --git a/requirements.txt b/requirements.txt index 43d077104f..c7d546578d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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