mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master' into api-default-values
# Conflicts: # InvenTree/InvenTree/serializers.py
This commit is contained in:
commit
ddbf863617
49
.github/workflows/python.yaml
vendored
Normal file
49
.github/workflows/python.yaml
vendored
Normal file
@ -0,0 +1,49 @@
|
||||
# Run python library tests whenever code is pushed to master
|
||||
|
||||
name: Python Bindings
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- l10*
|
||||
|
||||
jobs:
|
||||
|
||||
python:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
INVENTREE_DB_NAME: './test_db.sqlite'
|
||||
INVENTREE_DB_ENGINE: 'sqlite3'
|
||||
INVENTREE_DEBUG: info
|
||||
INVENTREE_MEDIA_ROOT: ./media
|
||||
INVENTREE_STATIC_ROOT: ./static
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v2
|
||||
- name: Install InvenTree
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install python3-dev python3-pip python3-venv
|
||||
pip3 install invoke
|
||||
invoke install
|
||||
invoke migrate
|
||||
- name: Download Python Code
|
||||
run: |
|
||||
git clone --depth 1 https://github.com/inventree/inventree-python ./inventree-python
|
||||
- name: Start Server
|
||||
run: |
|
||||
invoke import-records -f ./inventree-python/test/test_data.json
|
||||
invoke server -a 127.0.0.1:8000 &
|
||||
sleep 60
|
||||
- name: Run Tests
|
||||
run: |
|
||||
cd inventree-python
|
||||
invoke test
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -35,6 +35,9 @@ local_settings.py
|
||||
*.backup
|
||||
*.old
|
||||
|
||||
# Files used for testing
|
||||
dummy_image.*
|
||||
|
||||
# Sphinx files
|
||||
docs/_build
|
||||
|
||||
|
@ -73,22 +73,50 @@ class InvenTreeAPITestCase(APITestCase):
|
||||
ruleset.save()
|
||||
break
|
||||
|
||||
def get(self, url, data={}, code=200):
|
||||
def get(self, url, data={}, expected_code=200):
|
||||
"""
|
||||
Issue a GET request
|
||||
"""
|
||||
|
||||
response = self.client.get(url, data, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, code)
|
||||
if expected_code is not None:
|
||||
self.assertEqual(response.status_code, expected_code)
|
||||
|
||||
return response
|
||||
|
||||
def post(self, url, data):
|
||||
def post(self, url, data, expected_code=None):
|
||||
"""
|
||||
Issue a POST request
|
||||
"""
|
||||
|
||||
response = self.client.post(url, data=data, format='json')
|
||||
|
||||
if expected_code is not None:
|
||||
self.assertEqual(response.status_code, expected_code)
|
||||
|
||||
return response
|
||||
|
||||
def delete(self, url, expected_code=None):
|
||||
"""
|
||||
Issue a DELETE request
|
||||
"""
|
||||
|
||||
response = self.client.delete(url)
|
||||
|
||||
if expected_code is not None:
|
||||
self.assertEqual(response.status_code, expected_code)
|
||||
|
||||
return response
|
||||
|
||||
def patch(self, url, data, files=None, expected_code=None):
|
||||
"""
|
||||
Issue a PATCH request
|
||||
"""
|
||||
|
||||
response = self.client.patch(url, data=data, files=files, format='json')
|
||||
|
||||
if expected_code is not None:
|
||||
self.assertEqual(response.status_code, expected_code)
|
||||
|
||||
return response
|
||||
|
@ -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
|
||||
|
@ -13,6 +13,11 @@ import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
|
||||
from rest_framework import serializers
|
||||
from rest_framework.fields import empty
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
@ -69,18 +74,34 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
||||
|
||||
return super().is_valid(raise_exception=raise_exception)
|
||||
|
||||
def validate(self, data):
|
||||
def run_validation(self, data=empty):
|
||||
""" Perform serializer validation.
|
||||
In addition to running validators on the serializer fields,
|
||||
this class ensures that the underlying model is also validated.
|
||||
"""
|
||||
|
||||
# Run any native validation checks first (may throw an ValidationError)
|
||||
data = super(serializers.ModelSerializer, self).validate(data)
|
||||
# Run any native validation checks first (may raise a ValidationError)
|
||||
data = super().run_validation(data)
|
||||
|
||||
# Now ensure the underlying model is correct
|
||||
instance = self.Meta.model(**data)
|
||||
instance.clean()
|
||||
|
||||
if not hasattr(self, 'instance') or self.instance is None:
|
||||
# No instance exists (we are creating a new one)
|
||||
instance = self.Meta.model(**data)
|
||||
else:
|
||||
# Instance already exists (we are updating!)
|
||||
instance = self.instance
|
||||
|
||||
# Update instance fields
|
||||
for attr, value in data.items():
|
||||
setattr(instance, attr, value)
|
||||
|
||||
# Run a 'full_clean' on the model.
|
||||
# Note that by default, DRF does *not* perform full model validation!
|
||||
try:
|
||||
instance.full_clean()
|
||||
except (ValidationError, DjangoValidationError) as exc:
|
||||
raise ValidationError(detail=serializers.as_serializer_error(exc))
|
||||
|
||||
return data
|
||||
|
||||
@ -112,3 +133,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))
|
||||
|
@ -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,
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
||||
|
@ -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:
|
||||
@ -80,7 +80,7 @@ def heartbeat():
|
||||
|
||||
try:
|
||||
from django_q.models import Success
|
||||
logger.warning("Could not perform heartbeat task - App registry not ready")
|
||||
logger.info("Could not perform heartbeat task - App registry not ready")
|
||||
except AppRegistryNotReady:
|
||||
return
|
||||
|
||||
@ -105,7 +105,7 @@ def delete_successful_tasks():
|
||||
try:
|
||||
from django_q.models import Success
|
||||
except AppRegistryNotReady:
|
||||
logger.warning("Could not perform 'delete_successful_tasks' - App registry not ready")
|
||||
logger.info("Could not perform 'delete_successful_tasks' - App registry not ready")
|
||||
return
|
||||
|
||||
threshold = datetime.now() - timedelta(days=30)
|
||||
@ -126,6 +126,7 @@ def check_for_updates():
|
||||
import common.models
|
||||
except AppRegistryNotReady:
|
||||
# Apps not yet loaded!
|
||||
logger.info("Could not perform 'check_for_updates' - App registry not ready")
|
||||
return
|
||||
|
||||
response = requests.get('https://api.github.com/repos/inventree/inventree/releases/latest')
|
||||
@ -172,6 +173,7 @@ def update_exchange_rates():
|
||||
from django.conf import settings
|
||||
except AppRegistryNotReady:
|
||||
# Apps not yet loaded!
|
||||
logger.info("Could not perform 'update_exchange_rates' - App registry not ready")
|
||||
return
|
||||
except:
|
||||
# Other error?
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -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 """
|
||||
|
@ -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('/')
|
||||
|
@ -40,7 +40,8 @@ def assign_bom_items(apps, schema_editor):
|
||||
except BomItem.DoesNotExist:
|
||||
pass
|
||||
|
||||
print(f"Assigned BomItem for {count_valid}/{count_total} entries")
|
||||
if count_total > 0:
|
||||
print(f"Assigned BomItem for {count_valid}/{count_total} entries")
|
||||
|
||||
|
||||
def unassign_bom_items(apps, schema_editor):
|
||||
|
@ -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'),
|
||||
|
@ -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):
|
||||
|
@ -71,7 +71,8 @@ def migrate_currencies(apps, schema_editor):
|
||||
|
||||
count += 1
|
||||
|
||||
print(f"Updated {count} SupplierPriceBreak rows")
|
||||
if count > 0:
|
||||
print(f"Updated {count} SupplierPriceBreak rows")
|
||||
|
||||
def reverse_currencies(apps, schema_editor):
|
||||
"""
|
||||
|
@ -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)
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
@ -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):
|
||||
@ -119,7 +124,9 @@ class ManufacturerTest(InvenTreeAPITestCase):
|
||||
data = {
|
||||
'MPN': 'MPN-TEST-123',
|
||||
}
|
||||
|
||||
response = self.client.patch(url, data, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['MPN'], 'MPN-TEST-123')
|
||||
|
||||
|
@ -623,7 +623,8 @@ class SupplierPartEdit(AjaxUpdateView):
|
||||
supplier_part = self.get_object()
|
||||
|
||||
if supplier_part.manufacturer_part:
|
||||
initials['manufacturer'] = supplier_part.manufacturer_part.manufacturer.id
|
||||
if supplier_part.manufacturer_part.manufacturer:
|
||||
initials['manufacturer'] = supplier_part.manufacturer_part.manufacturer.id
|
||||
initials['MPN'] = supplier_part.manufacturer_part.MPN
|
||||
|
||||
return initials
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -157,7 +157,7 @@ class POList(generics.ListCreateAPIView):
|
||||
ordering = '-creation_date'
|
||||
|
||||
|
||||
class PODetail(generics.RetrieveUpdateAPIView):
|
||||
class PODetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
""" API endpoint for detail view of a PurchaseOrder object """
|
||||
|
||||
queryset = PurchaseOrder.objects.all()
|
||||
@ -382,7 +382,7 @@ class SOList(generics.ListCreateAPIView):
|
||||
ordering = '-creation_date'
|
||||
|
||||
|
||||
class SODetail(generics.RetrieveUpdateAPIView):
|
||||
class SODetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
"""
|
||||
API endpoint for detail view of a SalesOrder object.
|
||||
"""
|
||||
|
@ -93,8 +93,10 @@ class POSerializer(InvenTreeModelSerializer):
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
'reference',
|
||||
'status'
|
||||
'issue_date',
|
||||
'complete_date',
|
||||
'creation_date',
|
||||
]
|
||||
|
||||
|
||||
@ -110,8 +112,9 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
||||
self.fields.pop('part_detail')
|
||||
self.fields.pop('supplier_part_detail')
|
||||
|
||||
quantity = serializers.FloatField()
|
||||
received = serializers.FloatField()
|
||||
# TODO: Once https://github.com/inventree/InvenTree/issues/1687 is fixed, remove default values
|
||||
quantity = serializers.FloatField(default=1)
|
||||
received = serializers.FloatField(default=0)
|
||||
|
||||
part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True)
|
||||
supplier_part_detail = SupplierPartSerializer(source='part', many=False, read_only=True)
|
||||
@ -226,8 +229,9 @@ class SalesOrderSerializer(InvenTreeModelSerializer):
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
'reference',
|
||||
'status'
|
||||
'status',
|
||||
'creation_date',
|
||||
'shipment_date',
|
||||
]
|
||||
|
||||
|
||||
@ -313,7 +317,9 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
|
||||
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
||||
allocations = SalesOrderAllocationSerializer(many=True, read_only=True)
|
||||
|
||||
quantity = serializers.FloatField()
|
||||
# TODO: Once https://github.com/inventree/InvenTree/issues/1687 is fixed, remove default values
|
||||
quantity = serializers.FloatField(default=1)
|
||||
|
||||
allocated = serializers.FloatField(source='allocated_quantity', read_only=True)
|
||||
fulfilled = serializers.FloatField(source='fulfilled_quantity', read_only=True)
|
||||
sale_price_string = serializers.CharField(source='sale_price', read_only=True)
|
||||
|
@ -4,6 +4,8 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% block form %}
|
||||
{% default_currency as currency %}
|
||||
{% settings_value 'PART_SHOW_PRICE_IN_FORMS' as show_price %}
|
||||
|
||||
<h4>
|
||||
{% 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 }}">
|
||||
<option value=''>---------</option>
|
||||
{% 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 %}
|
||||
</select>
|
||||
</div>
|
||||
|
@ -57,8 +57,6 @@ $("#attachment-table").on('click', '.attachment-delete-button', function() {
|
||||
|
||||
var url = `/order/purchase-order/attachment/${button.attr('pk')}/delete/`;
|
||||
|
||||
console.log("url: " + url);
|
||||
|
||||
launchModalForm(url, {
|
||||
reload: true,
|
||||
});
|
||||
|
@ -110,6 +110,96 @@ class PurchaseOrderTest(OrderTest):
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_po_operations(self):
|
||||
"""
|
||||
Test that we can create / edit and delete a PurchaseOrder via the API
|
||||
"""
|
||||
|
||||
n = PurchaseOrder.objects.count()
|
||||
|
||||
url = reverse('api-po-list')
|
||||
|
||||
# Initially we do not have "add" permission for the PurchaseOrder model,
|
||||
# so this POST request should return 403
|
||||
response = self.post(
|
||||
url,
|
||||
{
|
||||
'supplier': 1,
|
||||
'reference': '123456789-xyz',
|
||||
'description': 'PO created via the API',
|
||||
},
|
||||
expected_code=403
|
||||
)
|
||||
|
||||
# And no new PurchaseOrder objects should have been created
|
||||
self.assertEqual(PurchaseOrder.objects.count(), n)
|
||||
|
||||
# Ok, now let's give this user the correct permission
|
||||
self.assignRole('purchase_order.add')
|
||||
|
||||
# Initially we do not have "add" permission for the PurchaseOrder model,
|
||||
# so this POST request should return 403
|
||||
response = self.post(
|
||||
url,
|
||||
{
|
||||
'supplier': 1,
|
||||
'reference': '123456789-xyz',
|
||||
'description': 'PO created via the API',
|
||||
},
|
||||
expected_code=201
|
||||
)
|
||||
|
||||
self.assertEqual(PurchaseOrder.objects.count(), n + 1)
|
||||
|
||||
pk = response.data['pk']
|
||||
|
||||
# Try to create a PO with identical reference (should fail!)
|
||||
response = self.post(
|
||||
url,
|
||||
{
|
||||
'supplier': 1,
|
||||
'reference': '123456789-xyz',
|
||||
'description': 'A different description',
|
||||
},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
self.assertEqual(PurchaseOrder.objects.count(), n + 1)
|
||||
|
||||
url = reverse('api-po-detail', kwargs={'pk': pk})
|
||||
|
||||
# Get detail info!
|
||||
response = self.get(url)
|
||||
self.assertEqual(response.data['pk'], pk)
|
||||
self.assertEqual(response.data['reference'], '123456789-xyz')
|
||||
|
||||
# Try to alter (edit) the PurchaseOrder
|
||||
response = self.patch(
|
||||
url,
|
||||
{
|
||||
'reference': '12345-abc',
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
# Reference should have changed
|
||||
self.assertEqual(response.data['reference'], '12345-abc')
|
||||
|
||||
# Now, let's try to delete it!
|
||||
# Initially, we do *not* have the required permission!
|
||||
response = self.delete(url, expected_code=403)
|
||||
|
||||
# Now, add the "delete" permission!
|
||||
self.assignRole("purchase_order.delete")
|
||||
|
||||
response = self.delete(url, expected_code=204)
|
||||
|
||||
# Number of PurchaseOrder objects should have decreased
|
||||
self.assertEqual(PurchaseOrder.objects.count(), n)
|
||||
|
||||
# And if we try to access the detail view again, it has gone
|
||||
response = self.get(url, expected_code=404)
|
||||
|
||||
|
||||
class SalesOrderTest(OrderTest):
|
||||
"""
|
||||
@ -158,8 +248,6 @@ class SalesOrderTest(OrderTest):
|
||||
|
||||
response = self.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
data = response.data
|
||||
|
||||
self.assertEqual(data['pk'], 1)
|
||||
@ -168,6 +256,87 @@ class SalesOrderTest(OrderTest):
|
||||
|
||||
url = reverse('api-so-attachment-list')
|
||||
|
||||
response = self.get(url)
|
||||
self.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
def test_so_operations(self):
|
||||
"""
|
||||
Test that we can create / edit and delete a SalesOrder via the API
|
||||
"""
|
||||
|
||||
n = SalesOrder.objects.count()
|
||||
|
||||
url = reverse('api-so-list')
|
||||
|
||||
# Initially we do not have "add" permission for the SalesOrder model,
|
||||
# so this POST request should return 403 (denied)
|
||||
response = self.post(
|
||||
url,
|
||||
{
|
||||
'customer': 4,
|
||||
'reference': '12345',
|
||||
'description': 'Sales order',
|
||||
},
|
||||
expected_code=403,
|
||||
)
|
||||
|
||||
self.assignRole('sales_order.add')
|
||||
|
||||
# Now we should be able to create a SalesOrder via the API
|
||||
response = self.post(
|
||||
url,
|
||||
{
|
||||
'customer': 4,
|
||||
'reference': '12345',
|
||||
'description': 'Sales order',
|
||||
},
|
||||
expected_code=201
|
||||
)
|
||||
|
||||
# Check that the new order has been created
|
||||
self.assertEqual(SalesOrder.objects.count(), n + 1)
|
||||
|
||||
# Grab the PK for the newly created SalesOrder
|
||||
pk = response.data['pk']
|
||||
|
||||
# Try to create a SO with identical reference (should fail)
|
||||
response = self.post(
|
||||
url,
|
||||
{
|
||||
'customer': 4,
|
||||
'reference': '12345',
|
||||
'description': 'Another sales order',
|
||||
},
|
||||
expected_code=400
|
||||
)
|
||||
|
||||
url = reverse('api-so-detail', kwargs={'pk': pk})
|
||||
|
||||
# Extract detail info for the SalesOrder
|
||||
response = self.get(url)
|
||||
self.assertEqual(response.data['reference'], '12345')
|
||||
|
||||
# Try to alter (edit) the SalesOrder
|
||||
response = self.patch(
|
||||
url,
|
||||
{
|
||||
'reference': '12345-a',
|
||||
},
|
||||
expected_code=200
|
||||
)
|
||||
|
||||
# Reference should have changed
|
||||
self.assertEqual(response.data['reference'], '12345-a')
|
||||
|
||||
# Now, let's try to delete this SalesOrder
|
||||
# Initially, we do not have the required permission
|
||||
response = self.delete(url, expected_code=403)
|
||||
|
||||
self.assignRole('sales_order.delete')
|
||||
|
||||
response = self.delete(url, expected_code=204)
|
||||
|
||||
# Check that the number of sales orders has decreased
|
||||
self.assertEqual(SalesOrder.objects.count(), n)
|
||||
|
||||
# And the resource should no longer be available
|
||||
response = self.get(url, expected_code=404)
|
||||
|
@ -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'
|
||||
|
@ -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):
|
||||
|
@ -6,7 +6,7 @@
|
||||
name: 'M2x4 LPHS'
|
||||
description: 'M2x4 low profile head screw'
|
||||
category: 8
|
||||
link: www.acme.com/parts/m2x4lphs
|
||||
link: http://www.acme.com/parts/m2x4lphs
|
||||
tree_id: 0
|
||||
purchaseable: True
|
||||
level: 0
|
||||
@ -56,6 +56,7 @@
|
||||
fields:
|
||||
name: 'C_22N_0805'
|
||||
description: '22nF capacitor in 0805 package'
|
||||
purchaseable: true
|
||||
category: 3
|
||||
tree_id: 0
|
||||
level: 0
|
||||
|
@ -71,7 +71,8 @@ def migrate_currencies(apps, schema_editor):
|
||||
|
||||
count += 1
|
||||
|
||||
print(f"Updated {count} SupplierPriceBreak rows")
|
||||
if count > 0:
|
||||
print(f"Updated {count} SupplierPriceBreak rows")
|
||||
|
||||
def reverse_currencies(apps, schema_editor):
|
||||
"""
|
||||
|
17
InvenTree/part/migrations/0068_part_unique_part.py
Normal file
17
InvenTree/part/migrations/0068_part_unique_part.py
Normal file
@ -0,0 +1,17 @@
|
||||
# Generated by Django 3.2.4 on 2021-06-21 23:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('part', '0067_partinternalpricebreak'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddConstraint(
|
||||
model_name='part',
|
||||
constraint=models.UniqueConstraint(fields=('name', 'IPN', 'revision'), name='unique_part'),
|
||||
),
|
||||
]
|
@ -321,6 +321,9 @@ class Part(MPTTModel):
|
||||
verbose_name = _("Part")
|
||||
verbose_name_plural = _("Parts")
|
||||
ordering = ['name', ]
|
||||
constraints = [
|
||||
UniqueConstraint(fields=['name', 'IPN', 'revision'], name='unique_part')
|
||||
]
|
||||
|
||||
class MPTTMeta:
|
||||
# For legacy reasons the 'variant_of' field is used to indicate the MPTT parent
|
||||
@ -379,7 +382,7 @@ class Part(MPTTModel):
|
||||
logger.info(f"Deleting unused image file '{previous.image}'")
|
||||
previous.image.delete(save=False)
|
||||
|
||||
self.clean()
|
||||
self.full_clean()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@ -642,23 +645,6 @@ class Part(MPTTModel):
|
||||
'IPN': _('Duplicate IPN not allowed in part settings'),
|
||||
})
|
||||
|
||||
# Part name uniqueness should be case insensitive
|
||||
try:
|
||||
parts = Part.objects.exclude(id=self.id).filter(
|
||||
name__iexact=self.name,
|
||||
IPN__iexact=self.IPN,
|
||||
revision__iexact=self.revision)
|
||||
|
||||
if parts.exists():
|
||||
msg = _("Part must be unique for name, IPN and revision")
|
||||
raise ValidationError({
|
||||
"name": msg,
|
||||
"IPN": msg,
|
||||
"revision": msg,
|
||||
})
|
||||
except Part.DoesNotExist:
|
||||
pass
|
||||
|
||||
def clean(self):
|
||||
"""
|
||||
Perform cleaning operations for the Part model
|
||||
@ -671,8 +657,6 @@ class Part(MPTTModel):
|
||||
|
||||
super().clean()
|
||||
|
||||
self.validate_unique()
|
||||
|
||||
if self.trackable:
|
||||
for part in self.get_used_in().all():
|
||||
|
||||
@ -1495,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
|
||||
@ -1592,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
|
||||
@ -2515,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
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
@ -1,14 +1,19 @@
|
||||
from rest_framework import status
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import PIL
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from part.models import Part
|
||||
from stock.models import StockItem
|
||||
from company.models import Company
|
||||
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
|
||||
|
||||
|
||||
class PartAPITest(InvenTreeAPITestCase):
|
||||
"""
|
||||
@ -230,6 +235,18 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
response = self.client.get(url, data={'part': 10004})
|
||||
self.assertEqual(len(response.data), 7)
|
||||
|
||||
# Try to post a new object (missing description)
|
||||
response = self.client.post(
|
||||
url,
|
||||
data={
|
||||
'part': 10000,
|
||||
'test_name': 'My very first test',
|
||||
'required': False,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
# Try to post a new object (should succeed)
|
||||
response = self.client.post(
|
||||
url,
|
||||
@ -237,6 +254,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
'part': 10000,
|
||||
'test_name': 'New Test',
|
||||
'required': True,
|
||||
'description': 'a test description'
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
@ -248,7 +266,8 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
url,
|
||||
data={
|
||||
'part': 10004,
|
||||
'test_name': " newtest"
|
||||
'test_name': " newtest",
|
||||
'description': 'dafsdf',
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
@ -316,6 +335,239 @@ class PartAPITest(InvenTreeAPITestCase):
|
||||
self.assertTrue(data['purchaseable'])
|
||||
|
||||
|
||||
class PartDetailTests(InvenTreeAPITestCase):
|
||||
"""
|
||||
Test that we can create / edit / delete Part objects via the API
|
||||
"""
|
||||
|
||||
fixtures = [
|
||||
'category',
|
||||
'part',
|
||||
'location',
|
||||
'bom',
|
||||
'test_templates',
|
||||
]
|
||||
|
||||
roles = [
|
||||
'part.change',
|
||||
'part.add',
|
||||
'part.delete',
|
||||
'part_category.change',
|
||||
'part_category.add',
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
def test_part_operations(self):
|
||||
n = Part.objects.count()
|
||||
|
||||
# Create a part
|
||||
response = self.client.post(
|
||||
reverse('api-part-list'),
|
||||
{
|
||||
'name': 'my test api part',
|
||||
'description': 'a part created with the API',
|
||||
'category': 1,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
||||
pk = response.data['pk']
|
||||
|
||||
# Check that a new part has been added
|
||||
self.assertEqual(Part.objects.count(), n + 1)
|
||||
|
||||
part = Part.objects.get(pk=pk)
|
||||
|
||||
self.assertEqual(part.name, 'my test api part')
|
||||
|
||||
# Edit the part
|
||||
url = reverse('api-part-detail', kwargs={'pk': pk})
|
||||
|
||||
# Let's change the name of the part
|
||||
|
||||
response = self.client.patch(url, {
|
||||
'name': 'a new better name',
|
||||
})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data['pk'], pk)
|
||||
self.assertEqual(response.data['name'], 'a new better name')
|
||||
|
||||
part = Part.objects.get(pk=pk)
|
||||
|
||||
# Name has been altered
|
||||
self.assertEqual(part.name, 'a new better name')
|
||||
|
||||
# Part count should not have changed
|
||||
self.assertEqual(Part.objects.count(), n + 1)
|
||||
|
||||
# Now, try to set the name to the *same* value
|
||||
# 2021-06-22 this test is to check that the "duplicate part" checks don't do strange things
|
||||
response = self.client.patch(url, {
|
||||
'name': 'a new better name',
|
||||
})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Try to remove the part
|
||||
response = self.client.delete(url)
|
||||
|
||||
self.assertEqual(response.status_code, 204)
|
||||
|
||||
# Part count should have reduced
|
||||
self.assertEqual(Part.objects.count(), n)
|
||||
|
||||
def test_duplicates(self):
|
||||
"""
|
||||
Check that trying to create 'duplicate' parts results in errors
|
||||
"""
|
||||
|
||||
# Create a part
|
||||
response = self.client.post(reverse('api-part-list'), {
|
||||
'name': 'part',
|
||||
'description': 'description',
|
||||
'IPN': 'IPN-123',
|
||||
'category': 1,
|
||||
'revision': 'A',
|
||||
})
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
||||
n = Part.objects.count()
|
||||
|
||||
# Check that we cannot create a duplicate in a different category
|
||||
response = self.client.post(reverse('api-part-list'), {
|
||||
'name': 'part',
|
||||
'description': 'description',
|
||||
'IPN': 'IPN-123',
|
||||
'category': 2,
|
||||
'revision': 'A',
|
||||
})
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
# Check that only 1 matching part exists
|
||||
parts = Part.objects.filter(
|
||||
name='part',
|
||||
description='description',
|
||||
IPN='IPN-123'
|
||||
)
|
||||
|
||||
self.assertEqual(parts.count(), 1)
|
||||
|
||||
# A new part should *not* have been created
|
||||
self.assertEqual(Part.objects.count(), n)
|
||||
|
||||
# But a different 'revision' *can* be created
|
||||
response = self.client.post(reverse('api-part-list'), {
|
||||
'name': 'part',
|
||||
'description': 'description',
|
||||
'IPN': 'IPN-123',
|
||||
'category': 2,
|
||||
'revision': 'B',
|
||||
})
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(Part.objects.count(), n + 1)
|
||||
|
||||
# Now, check that we cannot *change* an existing part to conflict
|
||||
pk = response.data['pk']
|
||||
|
||||
url = reverse('api-part-detail', kwargs={'pk': pk})
|
||||
|
||||
# Attempt to alter the revision code
|
||||
response = self.client.patch(
|
||||
url,
|
||||
{
|
||||
'revision': 'A',
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
# But we *can* change it to a unique revision code
|
||||
response = self.client.patch(
|
||||
url,
|
||||
{
|
||||
'revision': 'C',
|
||||
}
|
||||
)
|
||||
|
||||
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):
|
||||
"""
|
||||
Tests to ensure that the various aggregation annotations are working correctly...
|
||||
@ -342,6 +594,8 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||
# Add a new part
|
||||
self.part = Part.objects.create(
|
||||
name='Banana',
|
||||
description='This is a banana',
|
||||
category=PartCategory.objects.get(pk=1),
|
||||
)
|
||||
|
||||
# Create some stock items associated with the part
|
||||
|
@ -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')
|
||||
|
@ -11,7 +11,7 @@ from django.core.exceptions import ValidationError
|
||||
|
||||
import os
|
||||
|
||||
from .models import Part, PartTestTemplate
|
||||
from .models import Part, PartCategory, PartTestTemplate
|
||||
from .models import rename_part_image, match_part_names
|
||||
from .templatetags import inventree_extras
|
||||
|
||||
@ -78,6 +78,61 @@ class PartTest(TestCase):
|
||||
p = Part.objects.get(pk=100)
|
||||
self.assertEqual(str(p), "BOB | Bob | A2 - Can we build it?")
|
||||
|
||||
def test_duplicate(self):
|
||||
"""
|
||||
Test that we cannot create a "duplicate" Part
|
||||
"""
|
||||
|
||||
n = Part.objects.count()
|
||||
|
||||
cat = PartCategory.objects.get(pk=1)
|
||||
|
||||
Part.objects.create(
|
||||
category=cat,
|
||||
name='part',
|
||||
description='description',
|
||||
IPN='IPN',
|
||||
revision='A',
|
||||
)
|
||||
|
||||
self.assertEqual(Part.objects.count(), n + 1)
|
||||
|
||||
part = Part(
|
||||
category=cat,
|
||||
name='part',
|
||||
description='description',
|
||||
IPN='IPN',
|
||||
revision='A',
|
||||
)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
part.validate_unique()
|
||||
|
||||
try:
|
||||
part.save()
|
||||
self.assertTrue(False)
|
||||
except:
|
||||
pass
|
||||
|
||||
self.assertEqual(Part.objects.count(), n + 1)
|
||||
|
||||
# But we should be able to create a part with a different revision
|
||||
part_2 = Part.objects.create(
|
||||
category=cat,
|
||||
name='part',
|
||||
description='description',
|
||||
IPN='IPN',
|
||||
revision='B',
|
||||
)
|
||||
|
||||
self.assertEqual(Part.objects.count(), n + 2)
|
||||
|
||||
# Now, check that we cannot *change* part_2 to conflict
|
||||
part_2.revision = 'A'
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
part_2.validate_unique()
|
||||
|
||||
def test_metadata(self):
|
||||
self.assertEqual(self.r1.name, 'R_2K2_0805')
|
||||
self.assertEqual(self.r1.get_absolute_url(), '/part/3/')
|
||||
@ -277,21 +332,24 @@ class PartSettingsTest(TestCase):
|
||||
"""
|
||||
|
||||
# Create a part
|
||||
Part.objects.create(name='Hello', description='A thing', IPN='IPN123')
|
||||
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='A')
|
||||
|
||||
# Attempt to create a duplicate item (should fail)
|
||||
with self.assertRaises(ValidationError):
|
||||
Part.objects.create(name='Hello', description='A thing', IPN='IPN123')
|
||||
part = Part(name='Hello', description='A thing', IPN='IPN123', revision='A')
|
||||
part.validate_unique()
|
||||
|
||||
# Attempt to create item with duplicate IPN (should be allowed by default)
|
||||
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='B')
|
||||
|
||||
# And attempt again with the same values (should fail)
|
||||
with self.assertRaises(ValidationError):
|
||||
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='B')
|
||||
part = Part(name='Hello', description='A thing', IPN='IPN123', revision='B')
|
||||
part.validate_unique()
|
||||
|
||||
# Now update the settings so duplicate IPN values are *not* allowed
|
||||
InvenTreeSetting.set_setting('PART_ALLOW_DUPLICATE_IPN', False, self.user)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='C')
|
||||
part = Part(name='Hello', description='A thing', IPN='IPN123', revision='C')
|
||||
part.full_clean()
|
||||
|
@ -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
|
||||
|
@ -199,7 +199,8 @@ def update_history(apps, schema_editor):
|
||||
update_count += 1
|
||||
|
||||
|
||||
print(f"\n==========================\nUpdated {update_count} StockItemHistory entries")
|
||||
if update_count > 0:
|
||||
print(f"\n==========================\nUpdated {update_count} StockItemHistory entries")
|
||||
|
||||
|
||||
def reverse_update(apps, schema_editor):
|
||||
|
78
InvenTree/stock/migrations/0064_auto_20210621_1724.py
Normal file
78
InvenTree/stock/migrations/0064_auto_20210621_1724.py
Normal file
@ -0,0 +1,78 @@
|
||||
# Generated by Django 3.2.4 on 2021-06-21 07:24
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def extract_purchase_price(apps, schema_editor):
|
||||
"""
|
||||
Find instances of StockItem which do *not* have a purchase price set,
|
||||
but which point to a PurchaseOrder where there *is* a purchase price set.
|
||||
|
||||
Then, assign *that* purchase price to original StockItem.
|
||||
|
||||
This is to address an issue where older versions of InvenTree
|
||||
did not correctly copy purchase price information cross to the StockItem objects.
|
||||
|
||||
Current InvenTree version (as of 2021-06-21) copy this information across correctly,
|
||||
so this one-time data migration should suffice.
|
||||
"""
|
||||
|
||||
# Required database models
|
||||
StockItem = apps.get_model('stock', 'stockitem')
|
||||
PurchaseOrder = apps.get_model('order', 'purchaseorder')
|
||||
PurchaseOrderLineItem = apps.get_model('order', 'purchaseorderlineitem')
|
||||
Part = apps.get_model('part', 'part')
|
||||
|
||||
# Find all the StockItem objects without a purchase_price which point to a PurchaseOrder
|
||||
items = StockItem.objects.filter(purchase_price=None).exclude(purchase_order=None)
|
||||
|
||||
if items.count() > 0:
|
||||
print(f"Found {items.count()} stock items with missing purchase price information")
|
||||
|
||||
update_count = 0
|
||||
|
||||
for item in items:
|
||||
|
||||
part_id = item.part
|
||||
|
||||
po = item.purchase_order
|
||||
|
||||
# Look for a matching PurchaseOrderLineItem (with a price)
|
||||
lines = PurchaseOrderLineItem.objects.filter(part__part=part_id, order=po)
|
||||
|
||||
if lines.exists():
|
||||
|
||||
for line in lines:
|
||||
if line.purchase_price is not None:
|
||||
|
||||
# Copy pricing information across
|
||||
item.purchase_price = line.purchase_price
|
||||
item.purchases_price_currency = line.purchase_price_currency
|
||||
|
||||
print(f"- Updating supplier price for {item.part.name} - {item.purchase_price} {item.purchase_price_currency}")
|
||||
|
||||
update_count += 1
|
||||
|
||||
item.save()
|
||||
|
||||
break
|
||||
|
||||
if update_count > 0:
|
||||
print(f"Updated pricing for {update_count} stock items")
|
||||
|
||||
def reverse_operation(apps, schema_editor):
|
||||
"""
|
||||
DO NOTHING!
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stock', '0063_auto_20210511_2343'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(extract_purchase_price, reverse_code=reverse_operation)
|
||||
]
|
@ -350,7 +350,12 @@
|
||||
<tr>
|
||||
<td><span class='fas fa-industry'></span></td>
|
||||
<td>{% trans "Manufacturer" %}</td>
|
||||
<td><a href="{% url 'company-detail' item.supplier_part.manufacturer_part.manufacturer.id %}">{{ item.supplier_part.manufacturer_part.manufacturer.name }}</a></td>
|
||||
{% if item.supplier_part.manufacturer_part.manufacturer %}
|
||||
<td><a href="{% url 'company-detail' item.supplier_part.manufacturer_part.manufacturer.id %}">{{ item.supplier_part.manufacturer_part.manufacturer.name }}</a></td>
|
||||
{% else %}
|
||||
<td><i>{% trans "No manufacturer set" %}</i></td>
|
||||
{% endif %}
|
||||
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class='fas fa-hashtag'></span></td>
|
||||
|
@ -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" %}
|
||||
<tr><td colspan='5 '></td></tr>
|
||||
{% include "InvenTree/settings/setting.html" with key="PART_TEMPLATE" icon="fa-clone" %}
|
||||
|
@ -418,6 +418,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
sub_part_detail: true,
|
||||
sub_part_trackable: trackable,
|
||||
},
|
||||
disablePagination: true,
|
||||
formatNoMatches: function() {
|
||||
return '{% trans "No BOM items found" %}';
|
||||
},
|
||||
@ -667,6 +668,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
{
|
||||
field: 'sub_part_detail.stock',
|
||||
title: '{% trans "Available" %}',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
field: 'allocated',
|
||||
@ -686,14 +688,13 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
return makeProgressBar(allocated, required);
|
||||
},
|
||||
sorter: function(valA, valB, rowA, rowB) {
|
||||
// Custom sorting function for progress bars
|
||||
|
||||
var aA = sumAllocations(rowA);
|
||||
var aB = sumAllocations(rowB);
|
||||
|
||||
var qA = rowA.quantity;
|
||||
var qB = rowB.quantity;
|
||||
|
||||
qA *= output.quantity;
|
||||
qB *= output.quantity;
|
||||
var qA = requiredQuantity(rowA);
|
||||
var qB = requiredQuantity(rowB);
|
||||
|
||||
// Handle the case where both numerators are zero
|
||||
if ((aA == 0) && (aB == 0)) {
|
||||
@ -713,6 +714,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
|
||||
return (qA < qB) ? 1 : -1;
|
||||
}
|
||||
|
||||
if (progressA == progressB) return 0;
|
||||
|
||||
return (progressA < progressB) ? 1 : -1;
|
||||
}
|
||||
},
|
||||
|
@ -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) {
|
||||
@ -603,7 +608,6 @@ function loadStockTable(table, options) {
|
||||
|
||||
// REJECTED
|
||||
if (row.status == {{ StockStatus.REJECTED }}) {
|
||||
console.log("REJECTED - {{ StockStatus.REJECTED }}");
|
||||
html += makeIconBadge('fa-times-circle icon-red', '{% trans "Stock item has been rejected" %}');
|
||||
}
|
||||
// LOST
|
||||
|
@ -134,12 +134,14 @@ $.fn.inventreeTable = function(options) {
|
||||
var varName = tableName + '-pagesize';
|
||||
|
||||
// Pagingation options (can be server-side or client-side as specified by the caller)
|
||||
options.pagination = true;
|
||||
options.paginationVAlign = options.paginationVAlign || 'both';
|
||||
options.pageSize = inventreeLoad(varName, 25);
|
||||
options.pageList = [25, 50, 100, 250, 'all'];
|
||||
options.totalField = 'count';
|
||||
options.dataField = 'results';
|
||||
if (!options.disablePagination) {
|
||||
options.pagination = true;
|
||||
options.paginationVAlign = options.paginationVAlign || 'both';
|
||||
options.pageSize = inventreeLoad(varName, 25);
|
||||
options.pageList = [25, 50, 100, 250, 'all'];
|
||||
options.totalField = 'count';
|
||||
options.dataField = 'results';
|
||||
}
|
||||
|
||||
// Extract query params
|
||||
var filters = options.queryParams || options.filters || {};
|
||||
|
@ -13,7 +13,7 @@ class UsersConfig(AppConfig):
|
||||
|
||||
def ready(self):
|
||||
|
||||
if canAppAccessDatabase():
|
||||
if canAppAccessDatabase(allow_test=True):
|
||||
|
||||
try:
|
||||
self.assign_permissions()
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user