mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
commit
a2841d1bf5
3
.gitignore
vendored
3
.gitignore
vendored
@ -35,6 +35,9 @@ local_settings.py
|
|||||||
*.backup
|
*.backup
|
||||||
*.old
|
*.old
|
||||||
|
|
||||||
|
# Files used for testing
|
||||||
|
dummy_image.*
|
||||||
|
|
||||||
# Sphinx files
|
# Sphinx files
|
||||||
docs/_build
|
docs/_build
|
||||||
|
|
||||||
|
@ -109,12 +109,12 @@ class InvenTreeAPITestCase(APITestCase):
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def patch(self, url, data, expected_code=None):
|
def patch(self, url, data, files=None, expected_code=None):
|
||||||
"""
|
"""
|
||||||
Issue a PATCH request
|
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:
|
if expected_code is not None:
|
||||||
self.assertEqual(response.status_code, expected_code)
|
self.assertEqual(response.status_code, expected_code)
|
||||||
|
@ -12,7 +12,7 @@ def isInTestMode():
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def canAppAccessDatabase():
|
def canAppAccessDatabase(allow_test=False):
|
||||||
"""
|
"""
|
||||||
Returns True if the apps.py file can access database records.
|
Returns True if the apps.py file can access database records.
|
||||||
|
|
||||||
@ -39,6 +39,10 @@ def canAppAccessDatabase():
|
|||||||
'compilemessages',
|
'compilemessages',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if not allow_test:
|
||||||
|
# Override for testing mode?
|
||||||
|
excluded_commands.append('test')
|
||||||
|
|
||||||
for cmd in excluded_commands:
|
for cmd in excluded_commands:
|
||||||
if cmd in sys.argv:
|
if cmd in sys.argv:
|
||||||
return False
|
return False
|
||||||
|
@ -101,3 +101,17 @@ class InvenTreeAttachmentSerializerField(serializers.FileField):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
return os.path.join(str(settings.MEDIA_URL), str(value))
|
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))
|
||||||
|
@ -58,7 +58,7 @@ function inventreeFormDataUpload(url, data, options={}) {
|
|||||||
xhr.setRequestHeader('X-CSRFToken', csrftoken);
|
xhr.setRequestHeader('X-CSRFToken', csrftoken);
|
||||||
},
|
},
|
||||||
url: url,
|
url: url,
|
||||||
method: 'POST',
|
method: options.method || 'POST',
|
||||||
data: data,
|
data: data,
|
||||||
processData: false,
|
processData: false,
|
||||||
contentType: false,
|
contentType: false,
|
||||||
|
@ -219,6 +219,7 @@ function enableDragAndDrop(element, url, options) {
|
|||||||
data - Other form data to upload
|
data - Other form data to upload
|
||||||
success - Callback function in case of success
|
success - Callback function in case of success
|
||||||
error - Callback function in case of error
|
error - Callback function in case of error
|
||||||
|
method - HTTP method
|
||||||
*/
|
*/
|
||||||
|
|
||||||
data = options.data || {};
|
data = options.data || {};
|
||||||
@ -254,7 +255,8 @@ function enableDragAndDrop(element, url, options) {
|
|||||||
if (options.error) {
|
if (options.error) {
|
||||||
options.error(xhr, status, error);
|
options.error(xhr, status, error);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
method: options.method || 'POST',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
@ -61,21 +61,21 @@ def is_email_configured():
|
|||||||
|
|
||||||
# Display warning unless in test mode
|
# Display warning unless in test mode
|
||||||
if not settings.TESTING:
|
if not settings.TESTING:
|
||||||
logger.warning("EMAIL_HOST is not configured")
|
logger.debug("EMAIL_HOST is not configured")
|
||||||
|
|
||||||
if not settings.EMAIL_HOST_USER:
|
if not settings.EMAIL_HOST_USER:
|
||||||
configured = False
|
configured = False
|
||||||
|
|
||||||
# Display warning unless in test mode
|
# Display warning unless in test mode
|
||||||
if not settings.TESTING:
|
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:
|
if not settings.EMAIL_HOST_PASSWORD:
|
||||||
configured = False
|
configured = False
|
||||||
|
|
||||||
# Display warning unless in test mode
|
# Display warning unless in test mode
|
||||||
if not settings.TESTING:
|
if not settings.TESTING:
|
||||||
logger.warning("EMAIL_HOST_PASSWORD is not configured")
|
logger.debug("EMAIL_HOST_PASSWORD is not configured")
|
||||||
|
|
||||||
return configured
|
return configured
|
||||||
|
|
||||||
|
@ -28,7 +28,7 @@ def schedule_task(taskname, **kwargs):
|
|||||||
try:
|
try:
|
||||||
from django_q.models import Schedule
|
from django_q.models import Schedule
|
||||||
except (AppRegistryNotReady):
|
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
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -77,7 +77,7 @@ class APITests(InvenTreeAPITestCase):
|
|||||||
self.assertIn('version', data)
|
self.assertIn('version', data)
|
||||||
self.assertIn('instance', data)
|
self.assertIn('instance', data)
|
||||||
|
|
||||||
self.assertEquals('InvenTree', data['server'])
|
self.assertEqual('InvenTree', data['server'])
|
||||||
|
|
||||||
def test_role_view(self):
|
def test_role_view(self):
|
||||||
"""
|
"""
|
||||||
|
@ -10,23 +10,26 @@ import common.models
|
|||||||
|
|
||||||
INVENTREE_SW_VERSION = "0.2.4 pre"
|
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
|
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:
|
v6 -> 2021-06-23
|
||||||
- The updated StockItem "history tracking" now uses a different interface
|
- 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
|
v4 -> 2021-06-01
|
||||||
- BOM items can now accept "variant stock" to be assigned against them
|
- BOM items can now accept "variant stock" to be assigned against them
|
||||||
- Many slight API tweaks were needed to get this to work properly!
|
- Many slight API tweaks were needed to get this to work properly!
|
||||||
|
|
||||||
v5 -> 2021-06-21
|
v3 -> 2021-05-22:
|
||||||
- Adds API interface for manufacturer part parameters
|
- The updated StockItem "history tracking" now uses a different interface
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
INVENTREE_API_VERSION = 5
|
|
||||||
|
|
||||||
|
|
||||||
def inventreeInstanceName():
|
def inventreeInstanceName():
|
||||||
""" Returns the InstanceName settings for the current database """
|
""" Returns the InstanceName settings for the current database """
|
||||||
|
@ -337,7 +337,7 @@ class AjaxMixin(InvenTreeRoleMixin):
|
|||||||
# Do nothing by default
|
# Do nothing by default
|
||||||
pass
|
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.
|
""" Render a JSON response based on specific class context.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -349,6 +349,9 @@ class AjaxMixin(InvenTreeRoleMixin):
|
|||||||
Returns:
|
Returns:
|
||||||
JSON response object
|
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():
|
if not request.is_ajax():
|
||||||
return HttpResponseRedirect('/')
|
return HttpResponseRedirect('/')
|
||||||
|
@ -205,6 +205,13 @@ class InvenTreeSetting(models.Model):
|
|||||||
'validator': bool,
|
'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': {
|
'PART_INTERNAL_PRICE': {
|
||||||
'name': _('Internal Prices'),
|
'name': _('Internal Prices'),
|
||||||
'description': _('Enable internal prices for parts'),
|
'description': _('Enable internal prices for parts'),
|
||||||
|
@ -44,8 +44,6 @@ class CompanyConfig(AppConfig):
|
|||||||
company.image.render_variations(replace=False)
|
company.image.render_variations(replace=False)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
logger.warning(f"Image file '{company.image}' missing")
|
logger.warning(f"Image file '{company.image}' missing")
|
||||||
company.image = None
|
|
||||||
company.save()
|
|
||||||
except UnidentifiedImageError:
|
except UnidentifiedImageError:
|
||||||
logger.warning(f"Image file '{company.image}' is invalid")
|
logger.warning(f"Image file '{company.image}' is invalid")
|
||||||
except (OperationalError, ProgrammingError):
|
except (OperationalError, ProgrammingError):
|
||||||
|
@ -6,14 +6,15 @@ from rest_framework import serializers
|
|||||||
|
|
||||||
from sql_util.utils import SubqueryCount
|
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 Company
|
||||||
from .models import ManufacturerPart, ManufacturerPartParameter
|
from .models import ManufacturerPart, ManufacturerPartParameter
|
||||||
from .models import SupplierPart, SupplierPriceBreak
|
from .models import SupplierPart, SupplierPriceBreak
|
||||||
|
|
||||||
from InvenTree.serializers import InvenTreeModelSerializer
|
|
||||||
|
|
||||||
from part.serializers import PartBriefSerializer
|
|
||||||
|
|
||||||
|
|
||||||
class CompanyBriefSerializer(InvenTreeModelSerializer):
|
class CompanyBriefSerializer(InvenTreeModelSerializer):
|
||||||
""" Serializer for Company object (limited detail) """
|
""" Serializer for Company object (limited detail) """
|
||||||
@ -52,7 +53,7 @@ class CompanySerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
url = serializers.CharField(source='get_absolute_url', read_only=True)
|
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_supplied = serializers.IntegerField(read_only=True)
|
||||||
parts_manufactured = serializers.IntegerField(read_only=True)
|
parts_manufactured = serializers.IntegerField(read_only=True)
|
||||||
|
@ -139,13 +139,19 @@
|
|||||||
|
|
||||||
enableDragAndDrop(
|
enableDragAndDrop(
|
||||||
"#company-thumb",
|
"#company-thumb",
|
||||||
"{% url 'company-image' company.id %}",
|
"{% url 'api-company-detail' company.id %}",
|
||||||
{
|
{
|
||||||
label: 'image',
|
label: 'image',
|
||||||
|
method: 'PATCH',
|
||||||
success: function(data, status, xhr) {
|
success: function(data, status, xhr) {
|
||||||
|
|
||||||
|
if (data.image) {
|
||||||
|
$('#company-image').attr('src', data.image);
|
||||||
|
} else {
|
||||||
location.reload();
|
location.reload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
{% if company.image %}
|
{% if company.image %}
|
||||||
|
@ -50,10 +50,15 @@ class CompanyTest(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(response.data['name'], 'ACME')
|
self.assertEqual(response.data['name'], 'ACME')
|
||||||
|
|
||||||
# Change the name of the company
|
# Change the name of the company
|
||||||
|
# Note we should not have the correct permissions (yet)
|
||||||
data = response.data
|
data = response.data
|
||||||
data['name'] = 'ACMOO'
|
data['name'] = 'ACMOO'
|
||||||
response = self.client.patch(url, data, format='json')
|
response = self.client.patch(url, data, format='json', expected_code=400)
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
|
self.assignRole('company.change')
|
||||||
|
|
||||||
|
response = self.client.patch(url, data, format='json', expected_code=200)
|
||||||
|
|
||||||
self.assertEqual(response.data['name'], 'ACMOO')
|
self.assertEqual(response.data['name'], 'ACMOO')
|
||||||
|
|
||||||
def test_company_search(self):
|
def test_company_search(self):
|
||||||
|
@ -4,6 +4,8 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block form %}
|
{% block form %}
|
||||||
|
{% default_currency as currency %}
|
||||||
|
{% settings_value 'PART_SHOW_PRICE_IN_FORMS' as show_price %}
|
||||||
|
|
||||||
<h4>
|
<h4>
|
||||||
{% trans "Step 1 of 2 - Select Part Suppliers" %}
|
{% trans "Step 1 of 2 - Select Part Suppliers" %}
|
||||||
@ -49,7 +51,13 @@
|
|||||||
<select class='select' id='id_supplier_part_{{ part.id }}' name="part-supplier-{{ part.id }}">
|
<select class='select' id='id_supplier_part_{{ part.id }}' name="part-supplier-{{ part.id }}">
|
||||||
<option value=''>---------</option>
|
<option value=''>---------</option>
|
||||||
{% for supplier in part.supplier_parts.all %}
|
{% for supplier in part.supplier_parts.all %}
|
||||||
<option value="{{ supplier.id }}"{% if part.order_supplier == supplier.id %} selected="selected"{% endif %}>{{ supplier }}</option>
|
<option value="{{ supplier.id }}"{% if part.order_supplier == supplier.id %} selected="selected"{% endif %}>
|
||||||
|
{% if show_price %}
|
||||||
|
{% call_method supplier 'get_price' part.order_quantity as price %}
|
||||||
|
{% if price != None %}{% include "price.html" with price=price %}{% else %}{% trans 'No price' %}{% endif %} -
|
||||||
|
{% endif %}
|
||||||
|
{{ supplier }}
|
||||||
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1004,6 +1004,15 @@ class OrderParts(AjaxView):
|
|||||||
|
|
||||||
return ctx
|
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):
|
def get_suppliers(self):
|
||||||
""" Calculates a list of suppliers which the user will need to create POs for.
|
""" 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.
|
This is calculated AFTER the user finishes selecting the parts to order.
|
||||||
@ -1238,9 +1247,10 @@ class OrderParts(AjaxView):
|
|||||||
valid = False
|
valid = False
|
||||||
|
|
||||||
if form_step == 'select_parts':
|
if form_step == 'select_parts':
|
||||||
# No errors? Proceed to PO selection form
|
# No errors? and the price-update button was not used to submit? Proceed to PO selection form
|
||||||
if part_errors is False:
|
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.ajax_template_name = 'order/order_wizard/select_pos.html'
|
||||||
|
self.form_step = 'select_purchase_orders' # set step (important for get_data)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self.ajax_template_name = 'order/order_wizard/select_parts.html'
|
self.ajax_template_name = 'order/order_wizard/select_parts.html'
|
||||||
|
@ -39,7 +39,8 @@ class PartConfig(AppConfig):
|
|||||||
logger.debug("InvenTree: Checking Part image thumbnails")
|
logger.debug("InvenTree: Checking Part image thumbnails")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for part in Part.objects.all():
|
# Only check parts which have images
|
||||||
|
for part in Part.objects.exclude(image=None):
|
||||||
if part.image:
|
if part.image:
|
||||||
url = part.image.thumbnail.name
|
url = part.image.thumbnail.name
|
||||||
loc = os.path.join(settings.MEDIA_ROOT, url)
|
loc = os.path.join(settings.MEDIA_ROOT, url)
|
||||||
@ -50,8 +51,7 @@ class PartConfig(AppConfig):
|
|||||||
part.image.render_variations(replace=False)
|
part.image.render_variations(replace=False)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
logger.warning(f"Image file '{part.image}' missing")
|
logger.warning(f"Image file '{part.image}' missing")
|
||||||
part.image = None
|
pass
|
||||||
part.save()
|
|
||||||
except UnidentifiedImageError:
|
except UnidentifiedImageError:
|
||||||
logger.warning(f"Image file '{part.image}' is invalid")
|
logger.warning(f"Image file '{part.image}' is invalid")
|
||||||
except (OperationalError, ProgrammingError):
|
except (OperationalError, ProgrammingError):
|
||||||
|
@ -1479,16 +1479,17 @@ class Part(MPTTModel):
|
|||||||
|
|
||||||
return True
|
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
|
""" Return a simplified pricing string for this part
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
quantity: Number of units to calculate price for
|
quantity: Number of units to calculate price for
|
||||||
buy: Include supplier pricing (default = True)
|
buy: Include supplier pricing (default = True)
|
||||||
bom: Include BOM 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:
|
if price_range is None:
|
||||||
return None
|
return None
|
||||||
@ -1576,9 +1577,10 @@ class Part(MPTTModel):
|
|||||||
|
|
||||||
- Supplier price (if purchased from suppliers)
|
- Supplier price (if purchased from suppliers)
|
||||||
- BOM price (if built from other parts)
|
- BOM price (if built from other parts)
|
||||||
|
- Internal price (if set for the part)
|
||||||
|
|
||||||
Returns:
|
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
|
# only get internal price if set and should be used
|
||||||
@ -2499,7 +2501,9 @@ class BomItem(models.Model):
|
|||||||
def price_range(self):
|
def price_range(self):
|
||||||
""" Return the price-range for this BOM item. """
|
""" 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:
|
if prange is None:
|
||||||
return prange
|
return prange
|
||||||
|
@ -7,12 +7,15 @@ from decimal import Decimal
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.db.models.functions import Coalesce
|
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 rest_framework import serializers
|
||||||
from sql_util.utils import SubqueryCount, SubquerySum
|
from sql_util.utils import SubqueryCount, SubquerySum
|
||||||
from djmoney.contrib.django_rest_framework import MoneyField
|
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 stock.models import StockItem
|
||||||
|
|
||||||
from .models import (BomItem, Part, PartAttachment, PartCategory,
|
from .models import (BomItem, Part, PartAttachment, PartCategory,
|
||||||
@ -300,7 +303,7 @@ class PartSerializer(InvenTreeModelSerializer):
|
|||||||
stock_item_count = serializers.IntegerField(read_only=True)
|
stock_item_count = serializers.IntegerField(read_only=True)
|
||||||
suppliers = 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)
|
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
|
||||||
starred = serializers.SerializerMethodField()
|
starred = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
@ -239,13 +239,21 @@
|
|||||||
|
|
||||||
enableDragAndDrop(
|
enableDragAndDrop(
|
||||||
'#part-thumb',
|
'#part-thumb',
|
||||||
"{% url 'part-image-upload' part.id %}",
|
"{% url 'api-part-detail' part.id %}",
|
||||||
{
|
{
|
||||||
label: 'image',
|
label: 'image',
|
||||||
|
method: 'PATCH',
|
||||||
success: function(data, status, xhr) {
|
success: function(data, status, xhr) {
|
||||||
|
|
||||||
|
// If image / thumbnail data present, live update
|
||||||
|
if (data.image) {
|
||||||
|
$('#part-image').attr('src', data.image);
|
||||||
|
} else {
|
||||||
|
// Otherwise, reload the page
|
||||||
location.reload();
|
location.reload();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
$("#show-qr-code").click(function() {
|
$("#show-qr-code").click(function() {
|
||||||
|
@ -1,16 +1,19 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
from rest_framework import status
|
import PIL
|
||||||
|
|
||||||
from django.urls import reverse
|
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 part.models import Part, PartCategory
|
||||||
from stock.models import StockItem
|
from stock.models import StockItem
|
||||||
from company.models import Company
|
from company.models import Company
|
||||||
|
|
||||||
from InvenTree.api_tester import InvenTreeAPITestCase
|
|
||||||
from InvenTree.status_codes import StockStatus
|
|
||||||
|
|
||||||
|
|
||||||
class PartAPITest(InvenTreeAPITestCase):
|
class PartAPITest(InvenTreeAPITestCase):
|
||||||
"""
|
"""
|
||||||
@ -473,6 +476,74 @@ class PartDetailTests(InvenTreeAPITestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
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):
|
class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||||
"""
|
"""
|
||||||
|
@ -23,7 +23,7 @@ class TestParams(TestCase):
|
|||||||
def test_str(self):
|
def test_str(self):
|
||||||
|
|
||||||
t1 = PartParameterTemplate.objects.get(pk=1)
|
t1 = PartParameterTemplate.objects.get(pk=1)
|
||||||
self.assertEquals(str(t1), 'Length (mm)')
|
self.assertEqual(str(t1), 'Length (mm)')
|
||||||
|
|
||||||
p1 = PartParameter.objects.get(pk=1)
|
p1 = PartParameter.objects.get(pk=1)
|
||||||
self.assertEqual(str(p1), 'M2x4 LPHS : Length = 4mm')
|
self.assertEqual(str(p1), 'M2x4 LPHS : Length = 4mm')
|
||||||
|
@ -847,11 +847,13 @@ class PartPricingView(PartDetail):
|
|||||||
|
|
||||||
# BOM Information for Pie-Chart
|
# BOM Information for Pie-Chart
|
||||||
if part.has_bom:
|
if part.has_bom:
|
||||||
|
# get internal price setting
|
||||||
|
use_internal = InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
|
||||||
ctx_bom_parts = []
|
ctx_bom_parts = []
|
||||||
# iterate over all bom-items
|
# iterate over all bom-items
|
||||||
for item in part.bom_items.all():
|
for item in part.bom_items.all():
|
||||||
ctx_item = {'name': str(item.sub_part)}
|
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
|
price_min, price_max = 0, 0
|
||||||
if price: # check if price available
|
if price: # check if price available
|
||||||
|
@ -20,6 +20,7 @@
|
|||||||
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_DUPLICATE_IPN" %}
|
{% 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_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_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_RECENT_COUNT" icon="fa-clock" %}
|
||||||
<tr><td colspan='5 '></td></tr>
|
<tr><td colspan='5 '></td></tr>
|
||||||
{% include "InvenTree/settings/setting.html" with key="PART_TEMPLATE" icon="fa-clone" %}
|
{% include "InvenTree/settings/setting.html" with key="PART_TEMPLATE" icon="fa-clone" %}
|
||||||
|
@ -179,27 +179,32 @@ function loadStockTestResultsTable(table, options) {
|
|||||||
var match = false;
|
var match = false;
|
||||||
var override = false;
|
var override = false;
|
||||||
|
|
||||||
|
// Extract the simplified test key
|
||||||
var key = item.key;
|
var key = item.key;
|
||||||
|
|
||||||
// Attempt to associate this result with an existing test
|
// 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) {
|
if (key == row.key) {
|
||||||
|
|
||||||
item.test_name = row.test_name;
|
item.test_name = row.test_name;
|
||||||
item.required = row.required;
|
item.required = row.required;
|
||||||
|
|
||||||
match = true;
|
|
||||||
|
|
||||||
if (row.result == null) {
|
if (row.result == null) {
|
||||||
item.parent = parent_node;
|
item.parent = parent_node;
|
||||||
tableData[index] = item;
|
tableData[idx] = item;
|
||||||
override = true;
|
override = true;
|
||||||
} else {
|
} else {
|
||||||
item.parent = row.pk;
|
item.parent = row.pk;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
match = true;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// No match could be found
|
// No match could be found
|
||||||
if (!match) {
|
if (!match) {
|
||||||
|
@ -13,7 +13,7 @@ class UsersConfig(AppConfig):
|
|||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
|
|
||||||
if canAppAccessDatabase():
|
if canAppAccessDatabase(allow_test=True):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.assign_permissions()
|
self.assign_permissions()
|
||||||
|
@ -276,7 +276,7 @@ def update_group_roles(group, debug=False):
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not canAppAccessDatabase():
|
if not canAppAccessDatabase(allow_test=True):
|
||||||
return
|
return
|
||||||
|
|
||||||
# List of permissions already associated with this group
|
# List of permissions already associated with this group
|
||||||
|
Loading…
Reference in New Issue
Block a user