diff --git a/.gitignore b/.gitignore index 5dd3580ef6..c53a837e24 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,9 @@ local_settings.py *.backup *.old +# Files used for testing +dummy_image.* + # Sphinx files docs/_build diff --git a/InvenTree/InvenTree/api_tester.py b/InvenTree/InvenTree/api_tester.py index f0f33ad1a5..a803e6797f 100644 --- a/InvenTree/InvenTree/api_tester.py +++ b/InvenTree/InvenTree/api_tester.py @@ -109,12 +109,12 @@ class InvenTreeAPITestCase(APITestCase): return response - def patch(self, url, data, expected_code=None): + def patch(self, url, data, files=None, expected_code=None): """ Issue a PATCH request """ - response = self.client.patch(url, data=data, format='json') + response = self.client.patch(url, data=data, files=files, format='json') if expected_code is not None: self.assertEqual(response.status_code, expected_code) diff --git a/InvenTree/InvenTree/ready.py b/InvenTree/InvenTree/ready.py index 4acbcae9af..7d63861f4b 100644 --- a/InvenTree/InvenTree/ready.py +++ b/InvenTree/InvenTree/ready.py @@ -12,7 +12,7 @@ def isInTestMode(): return False -def canAppAccessDatabase(): +def canAppAccessDatabase(allow_test=False): """ Returns True if the apps.py file can access database records. @@ -39,6 +39,10 @@ def canAppAccessDatabase(): 'compilemessages', ] + if not allow_test: + # Override for testing mode? + excluded_commands.append('test') + for cmd in excluded_commands: if cmd in sys.argv: return False diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py index 2bc89ef637..f093255ff0 100644 --- a/InvenTree/InvenTree/serializers.py +++ b/InvenTree/InvenTree/serializers.py @@ -101,3 +101,17 @@ class InvenTreeAttachmentSerializerField(serializers.FileField): return None return os.path.join(str(settings.MEDIA_URL), str(value)) + + +class InvenTreeImageSerializerField(serializers.ImageField): + """ + Custom image serializer. + On upload, validate that the file is a valid image file + """ + + def to_representation(self, value): + + if not value: + return None + + return os.path.join(str(settings.MEDIA_URL), str(value)) diff --git a/InvenTree/InvenTree/static/script/inventree/api.js b/InvenTree/InvenTree/static/script/inventree/api.js index 52aba80ef5..b43bcc8419 100644 --- a/InvenTree/InvenTree/static/script/inventree/api.js +++ b/InvenTree/InvenTree/static/script/inventree/api.js @@ -58,7 +58,7 @@ function inventreeFormDataUpload(url, data, options={}) { xhr.setRequestHeader('X-CSRFToken', csrftoken); }, url: url, - method: 'POST', + method: options.method || 'POST', data: data, processData: false, contentType: false, diff --git a/InvenTree/InvenTree/static/script/inventree/inventree.js b/InvenTree/InvenTree/static/script/inventree/inventree.js index 1a6fdec47a..79e02d7da5 100644 --- a/InvenTree/InvenTree/static/script/inventree/inventree.js +++ b/InvenTree/InvenTree/static/script/inventree/inventree.js @@ -219,6 +219,7 @@ function enableDragAndDrop(element, url, options) { data - Other form data to upload success - Callback function in case of success error - Callback function in case of error + method - HTTP method */ data = options.data || {}; @@ -254,7 +255,8 @@ function enableDragAndDrop(element, url, options) { if (options.error) { options.error(xhr, status, error); } - } + }, + method: options.method || 'POST', } ); } else { diff --git a/InvenTree/InvenTree/status.py b/InvenTree/InvenTree/status.py index 93b904d55b..512c68e93b 100644 --- a/InvenTree/InvenTree/status.py +++ b/InvenTree/InvenTree/status.py @@ -61,21 +61,21 @@ def is_email_configured(): # Display warning unless in test mode if not settings.TESTING: - logger.warning("EMAIL_HOST is not configured") + logger.debug("EMAIL_HOST is not configured") if not settings.EMAIL_HOST_USER: configured = False # Display warning unless in test mode if not settings.TESTING: - logger.warning("EMAIL_HOST_USER is not configured") + logger.debug("EMAIL_HOST_USER is not configured") if not settings.EMAIL_HOST_PASSWORD: configured = False # Display warning unless in test mode if not settings.TESTING: - logger.warning("EMAIL_HOST_PASSWORD is not configured") + logger.debug("EMAIL_HOST_PASSWORD is not configured") return configured diff --git a/InvenTree/InvenTree/tasks.py b/InvenTree/InvenTree/tasks.py index 4fb78cfbab..f0fe504072 100644 --- a/InvenTree/InvenTree/tasks.py +++ b/InvenTree/InvenTree/tasks.py @@ -28,7 +28,7 @@ def schedule_task(taskname, **kwargs): try: from django_q.models import Schedule except (AppRegistryNotReady): - logger.warning("Could not start background tasks - App registry not ready") + logger.info("Could not start background tasks - App registry not ready") return try: diff --git a/InvenTree/InvenTree/test_api.py b/InvenTree/InvenTree/test_api.py index 8435d756fb..f196006df9 100644 --- a/InvenTree/InvenTree/test_api.py +++ b/InvenTree/InvenTree/test_api.py @@ -77,7 +77,7 @@ class APITests(InvenTreeAPITestCase): self.assertIn('version', data) self.assertIn('instance', data) - self.assertEquals('InvenTree', data['server']) + self.assertEqual('InvenTree', data['server']) def test_role_view(self): """ diff --git a/InvenTree/InvenTree/version.py b/InvenTree/InvenTree/version.py index a47ef3b667..08fa5e0ae4 100644 --- a/InvenTree/InvenTree/version.py +++ b/InvenTree/InvenTree/version.py @@ -10,23 +10,26 @@ import common.models INVENTREE_SW_VERSION = "0.2.4 pre" +INVENTREE_API_VERSION = 6 + """ Increment thi API version number whenever there is a significant change to the API that any clients need to know about -v3 -> 2021-05-22: - - The updated StockItem "history tracking" now uses a different interface +v6 -> 2021-06-23 + - Part and Company images can now be directly uploaded via the REST API + +v5 -> 2021-06-21 + - Adds API interface for manufacturer part parameters v4 -> 2021-06-01 - BOM items can now accept "variant stock" to be assigned against them - Many slight API tweaks were needed to get this to work properly! -v5 -> 2021-06-21 - - Adds API interface for manufacturer part parameters +v3 -> 2021-05-22: + - The updated StockItem "history tracking" now uses a different interface """ -INVENTREE_API_VERSION = 5 - def inventreeInstanceName(): """ Returns the InstanceName settings for the current database """ diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py index 06aec54c18..17caeb872d 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -337,7 +337,7 @@ class AjaxMixin(InvenTreeRoleMixin): # Do nothing by default pass - def renderJsonResponse(self, request, form=None, data={}, context=None): + def renderJsonResponse(self, request, form=None, data=None, context=None): """ Render a JSON response based on specific class context. Args: @@ -349,6 +349,9 @@ class AjaxMixin(InvenTreeRoleMixin): Returns: JSON response object """ + # a empty dict as default can be dangerous - set it here if empty + if not data: + data = {} if not request.is_ajax(): return HttpResponseRedirect('/') diff --git a/InvenTree/common/models.py b/InvenTree/common/models.py index 31a0031c48..b78fe1c2bf 100644 --- a/InvenTree/common/models.py +++ b/InvenTree/common/models.py @@ -205,6 +205,13 @@ class InvenTreeSetting(models.Model): 'validator': bool, }, + 'PART_SHOW_PRICE_IN_FORMS': { + 'name': _('Show Price in Forms'), + 'description': _('Display part price in some forms'), + 'default': True, + 'validator': bool, + }, + 'PART_INTERNAL_PRICE': { 'name': _('Internal Prices'), 'description': _('Enable internal prices for parts'), diff --git a/InvenTree/company/apps.py b/InvenTree/company/apps.py index 4366f63434..76798c5ad4 100644 --- a/InvenTree/company/apps.py +++ b/InvenTree/company/apps.py @@ -44,8 +44,6 @@ class CompanyConfig(AppConfig): company.image.render_variations(replace=False) except FileNotFoundError: logger.warning(f"Image file '{company.image}' missing") - company.image = None - company.save() except UnidentifiedImageError: logger.warning(f"Image file '{company.image}' is invalid") except (OperationalError, ProgrammingError): diff --git a/InvenTree/company/serializers.py b/InvenTree/company/serializers.py index 471d26bd3f..1e97756987 100644 --- a/InvenTree/company/serializers.py +++ b/InvenTree/company/serializers.py @@ -6,14 +6,15 @@ from rest_framework import serializers from sql_util.utils import SubqueryCount +from InvenTree.serializers import InvenTreeModelSerializer +from InvenTree.serializers import InvenTreeImageSerializerField + +from part.serializers import PartBriefSerializer + from .models import Company from .models import ManufacturerPart, ManufacturerPartParameter from .models import SupplierPart, SupplierPriceBreak -from InvenTree.serializers import InvenTreeModelSerializer - -from part.serializers import PartBriefSerializer - class CompanyBriefSerializer(InvenTreeModelSerializer): """ Serializer for Company object (limited detail) """ @@ -52,7 +53,7 @@ class CompanySerializer(InvenTreeModelSerializer): url = serializers.CharField(source='get_absolute_url', read_only=True) - image = serializers.CharField(source='get_thumbnail_url', read_only=True) + image = InvenTreeImageSerializerField(required=False, allow_null=True) parts_supplied = serializers.IntegerField(read_only=True) parts_manufactured = serializers.IntegerField(read_only=True) diff --git a/InvenTree/company/templates/company/company_base.html b/InvenTree/company/templates/company/company_base.html index 1eefade272..a276f5df4f 100644 --- a/InvenTree/company/templates/company/company_base.html +++ b/InvenTree/company/templates/company/company_base.html @@ -139,11 +139,17 @@ enableDragAndDrop( "#company-thumb", - "{% url 'company-image' company.id %}", + "{% url 'api-company-detail' company.id %}", { label: 'image', + method: 'PATCH', success: function(data, status, xhr) { - location.reload(); + + if (data.image) { + $('#company-image').attr('src', data.image); + } else { + location.reload(); + } } } ); diff --git a/InvenTree/company/test_api.py b/InvenTree/company/test_api.py index d5cb573f47..dd42b97801 100644 --- a/InvenTree/company/test_api.py +++ b/InvenTree/company/test_api.py @@ -50,10 +50,15 @@ class CompanyTest(InvenTreeAPITestCase): self.assertEqual(response.data['name'], 'ACME') # Change the name of the company + # Note we should not have the correct permissions (yet) data = response.data data['name'] = 'ACMOO' - response = self.client.patch(url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_200_OK) + response = self.client.patch(url, data, format='json', expected_code=400) + + self.assignRole('company.change') + + response = self.client.patch(url, data, format='json', expected_code=200) + self.assertEqual(response.data['name'], 'ACMOO') def test_company_search(self): diff --git a/InvenTree/order/templates/order/order_wizard/select_parts.html b/InvenTree/order/templates/order/order_wizard/select_parts.html index 28b4a36213..a02113fa18 100644 --- a/InvenTree/order/templates/order/order_wizard/select_parts.html +++ b/InvenTree/order/templates/order/order_wizard/select_parts.html @@ -4,6 +4,8 @@ {% load i18n %} {% block form %} +{% default_currency as currency %} +{% settings_value 'PART_SHOW_PRICE_IN_FORMS' as show_price %}

{% trans "Step 1 of 2 - Select Part Suppliers" %} @@ -49,7 +51,13 @@ diff --git a/InvenTree/order/views.py b/InvenTree/order/views.py index c8ec42d3e7..4a8e576a6d 100644 --- a/InvenTree/order/views.py +++ b/InvenTree/order/views.py @@ -1004,6 +1004,15 @@ class OrderParts(AjaxView): return ctx + def get_data(self): + """ enrich respone json data """ + data = super().get_data() + # if in selection-phase, add a button to update the prices + if getattr(self, 'form_step', 'select_parts') == 'select_parts': + data['buttons'] = [{'name': 'update_price', 'title': _('Update prices')}] # set buttons + data['hideErrorMessage'] = '1' # hide the error message + return data + def get_suppliers(self): """ Calculates a list of suppliers which the user will need to create POs for. This is calculated AFTER the user finishes selecting the parts to order. @@ -1238,9 +1247,10 @@ class OrderParts(AjaxView): valid = False if form_step == 'select_parts': - # No errors? Proceed to PO selection form - if part_errors is False: + # No errors? and the price-update button was not used to submit? Proceed to PO selection form + if part_errors is False and 'act-btn_update_price' not in request.POST: self.ajax_template_name = 'order/order_wizard/select_pos.html' + self.form_step = 'select_purchase_orders' # set step (important for get_data) else: self.ajax_template_name = 'order/order_wizard/select_parts.html' diff --git a/InvenTree/part/apps.py b/InvenTree/part/apps.py index 1b233bccac..0c57e2c1ab 100644 --- a/InvenTree/part/apps.py +++ b/InvenTree/part/apps.py @@ -39,7 +39,8 @@ class PartConfig(AppConfig): logger.debug("InvenTree: Checking Part image thumbnails") try: - for part in Part.objects.all(): + # Only check parts which have images + for part in Part.objects.exclude(image=None): if part.image: url = part.image.thumbnail.name loc = os.path.join(settings.MEDIA_ROOT, url) @@ -50,8 +51,7 @@ class PartConfig(AppConfig): part.image.render_variations(replace=False) except FileNotFoundError: logger.warning(f"Image file '{part.image}' missing") - part.image = None - part.save() + pass except UnidentifiedImageError: logger.warning(f"Image file '{part.image}' is invalid") except (OperationalError, ProgrammingError): diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py index 8ddf049216..2b97fde596 100644 --- a/InvenTree/part/models.py +++ b/InvenTree/part/models.py @@ -1479,16 +1479,17 @@ class Part(MPTTModel): return True - def get_price_info(self, quantity=1, buy=True, bom=True): + def get_price_info(self, quantity=1, buy=True, bom=True, internal=False): """ Return a simplified pricing string for this part Args: quantity: Number of units to calculate price for buy: Include supplier pricing (default = True) bom: Include BOM pricing (default = True) + internal: Include internal pricing (default = False) """ - price_range = self.get_price_range(quantity, buy, bom) + price_range = self.get_price_range(quantity, buy, bom, internal) if price_range is None: return None @@ -1576,9 +1577,10 @@ class Part(MPTTModel): - Supplier price (if purchased from suppliers) - BOM price (if built from other parts) + - Internal price (if set for the part) Returns: - Minimum of the supplier price or BOM price. If no pricing available, returns None + Minimum of the supplier, BOM or internal price. If no pricing available, returns None """ # only get internal price if set and should be used @@ -2499,7 +2501,9 @@ class BomItem(models.Model): def price_range(self): """ Return the price-range for this BOM item. """ - prange = self.sub_part.get_price_range(self.quantity) + # get internal price setting + use_internal = common.models.InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False) + prange = self.sub_part.get_price_range(self.quantity, intenal=use_internal) if prange is None: return prange diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 76275bd5e1..6c47f1310f 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -7,12 +7,15 @@ from decimal import Decimal from django.db import models from django.db.models import Q from django.db.models.functions import Coalesce -from InvenTree.serializers import (InvenTreeAttachmentSerializerField, - InvenTreeModelSerializer) -from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus + from rest_framework import serializers from sql_util.utils import SubqueryCount, SubquerySum from djmoney.contrib.django_rest_framework import MoneyField + +from InvenTree.serializers import (InvenTreeAttachmentSerializerField, + InvenTreeImageSerializerField, + InvenTreeModelSerializer) +from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus from stock.models import StockItem from .models import (BomItem, Part, PartAttachment, PartCategory, @@ -300,7 +303,7 @@ class PartSerializer(InvenTreeModelSerializer): stock_item_count = serializers.IntegerField(read_only=True) suppliers = serializers.IntegerField(read_only=True) - image = serializers.CharField(source='get_image_url', read_only=True) + image = InvenTreeImageSerializerField(required=False, allow_null=True) thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True) starred = serializers.SerializerMethodField() diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html index 7e1d33bdea..b486e2c589 100644 --- a/InvenTree/part/templates/part/part_base.html +++ b/InvenTree/part/templates/part/part_base.html @@ -239,11 +239,19 @@ enableDragAndDrop( '#part-thumb', - "{% url 'part-image-upload' part.id %}", + "{% url 'api-part-detail' part.id %}", { label: 'image', + method: 'PATCH', success: function(data, status, xhr) { - location.reload(); + + // If image / thumbnail data present, live update + if (data.image) { + $('#part-image').attr('src', data.image); + } else { + // Otherwise, reload the page + location.reload(); + } } } ); diff --git a/InvenTree/part/test_api.py b/InvenTree/part/test_api.py index 176149c880..d606b12637 100644 --- a/InvenTree/part/test_api.py +++ b/InvenTree/part/test_api.py @@ -1,16 +1,19 @@ # -*- coding: utf-8 -*- -from rest_framework import status +import PIL from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from InvenTree.api_tester import InvenTreeAPITestCase +from InvenTree.status_codes import StockStatus + from part.models import Part, PartCategory from stock.models import StockItem from company.models import Company -from InvenTree.api_tester import InvenTreeAPITestCase -from InvenTree.status_codes import StockStatus - class PartAPITest(InvenTreeAPITestCase): """ @@ -473,6 +476,74 @@ class PartDetailTests(InvenTreeAPITestCase): self.assertEqual(response.status_code, 200) + def test_image_upload(self): + """ + Test that we can upload an image to the part API + """ + + self.assignRole('part.add') + + # Create a new part + response = self.client.post( + reverse('api-part-list'), + { + 'name': 'imagine', + 'description': 'All the people', + 'category': 1, + }, + expected_code=201 + ) + + pk = response.data['pk'] + + url = reverse('api-part-detail', kwargs={'pk': pk}) + + p = Part.objects.get(pk=pk) + + # Part should not have an image! + with self.assertRaises(ValueError): + print(p.image.file) + + # Create a custom APIClient for file uploads + # Ref: https://stackoverflow.com/questions/40453947/how-to-generate-a-file-upload-test-request-with-django-rest-frameworks-apireq + upload_client = APIClient() + upload_client.force_authenticate(user=self.user) + + # Try to upload a non-image file + with open('dummy_image.txt', 'w') as dummy_image: + dummy_image.write('hello world') + + with open('dummy_image.txt', 'rb') as dummy_image: + response = upload_client.patch( + url, + { + 'image': dummy_image, + }, + format='multipart', + ) + + self.assertEqual(response.status_code, 400) + + # Now try to upload a valid image file + img = PIL.Image.new('RGB', (128, 128), color='red') + img.save('dummy_image.jpg') + + with open('dummy_image.jpg', 'rb') as dummy_image: + response = upload_client.patch( + url, + { + 'image': dummy_image, + }, + format='multipart', + ) + + self.assertEqual(response.status_code, 200) + + # And now check that the image has been set + p = Part.objects.get(pk=pk) + + print("Image:", p.image.file) + class PartAPIAggregationTest(InvenTreeAPITestCase): """ diff --git a/InvenTree/part/test_param.py b/InvenTree/part/test_param.py index 4e6556e63d..85eea7ee57 100644 --- a/InvenTree/part/test_param.py +++ b/InvenTree/part/test_param.py @@ -23,7 +23,7 @@ class TestParams(TestCase): def test_str(self): t1 = PartParameterTemplate.objects.get(pk=1) - self.assertEquals(str(t1), 'Length (mm)') + self.assertEqual(str(t1), 'Length (mm)') p1 = PartParameter.objects.get(pk=1) self.assertEqual(str(p1), 'M2x4 LPHS : Length = 4mm') diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py index 39e0f13b45..743b3a36b9 100644 --- a/InvenTree/part/views.py +++ b/InvenTree/part/views.py @@ -847,11 +847,13 @@ class PartPricingView(PartDetail): # BOM Information for Pie-Chart if part.has_bom: + # get internal price setting + use_internal = InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False) ctx_bom_parts = [] # iterate over all bom-items for item in part.bom_items.all(): ctx_item = {'name': str(item.sub_part)} - price, qty = item.sub_part.get_price_range(quantity), item.quantity + price, qty = item.sub_part.get_price_range(quantity, internal=use_internal), item.quantity price_min, price_max = 0, 0 if price: # check if price available diff --git a/InvenTree/templates/InvenTree/settings/part.html b/InvenTree/templates/InvenTree/settings/part.html index 08b53be022..1bf5f8794a 100644 --- a/InvenTree/templates/InvenTree/settings/part.html +++ b/InvenTree/templates/InvenTree/settings/part.html @@ -20,6 +20,7 @@ {% include "InvenTree/settings/setting.html" with key="PART_ALLOW_DUPLICATE_IPN" %} {% include "InvenTree/settings/setting.html" with key="PART_ALLOW_EDIT_IPN" %} {% include "InvenTree/settings/setting.html" with key="PART_SHOW_QUANTITY_IN_FORMS" icon="fa-hashtag" %} + {% include "InvenTree/settings/setting.html" with key="PART_SHOW_PRICE_IN_FORMS" icon="fa-dollar-sign" %} {% include "InvenTree/settings/setting.html" with key="PART_RECENT_COUNT" icon="fa-clock" %} {% include "InvenTree/settings/setting.html" with key="PART_TEMPLATE" icon="fa-clone" %} diff --git a/InvenTree/templates/js/stock.js b/InvenTree/templates/js/stock.js index 06bbb7c20e..b557f0c327 100644 --- a/InvenTree/templates/js/stock.js +++ b/InvenTree/templates/js/stock.js @@ -179,27 +179,32 @@ function loadStockTestResultsTable(table, options) { var match = false; var override = false; + // Extract the simplified test key var key = item.key; // Attempt to associate this result with an existing test - tableData.forEach(function(row, index) { + for (var idx = 0; idx < tableData.length; idx++) { + + var row = tableData[idx]; if (key == row.key) { item.test_name = row.test_name; item.required = row.required; - match = true; - if (row.result == null) { item.parent = parent_node; - tableData[index] = item; + tableData[idx] = item; override = true; } else { item.parent = row.pk; } + + match = true; + + break; } - }); + } // No match could be found if (!match) { diff --git a/InvenTree/users/apps.py b/InvenTree/users/apps.py index a9f671895d..f6666e3a94 100644 --- a/InvenTree/users/apps.py +++ b/InvenTree/users/apps.py @@ -13,7 +13,7 @@ class UsersConfig(AppConfig): def ready(self): - if canAppAccessDatabase(): + if canAppAccessDatabase(allow_test=True): try: self.assign_permissions() diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index 09d70c3501..2763ba0e10 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -276,7 +276,7 @@ def update_group_roles(group, debug=False): """ - if not canAppAccessDatabase(): + if not canAppAccessDatabase(allow_test=True): return # List of permissions already associated with this group