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
6457250776
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
|
||||||
|
|
@ -73,22 +73,50 @@ class InvenTreeAPITestCase(APITestCase):
|
|||||||
ruleset.save()
|
ruleset.save()
|
||||||
break
|
break
|
||||||
|
|
||||||
def get(self, url, data={}, code=200):
|
def get(self, url, data={}, expected_code=200):
|
||||||
"""
|
"""
|
||||||
Issue a GET request
|
Issue a GET request
|
||||||
"""
|
"""
|
||||||
|
|
||||||
response = self.client.get(url, data, format='json')
|
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
|
return response
|
||||||
|
|
||||||
def post(self, url, data):
|
def post(self, url, data, expected_code=None):
|
||||||
"""
|
"""
|
||||||
Issue a POST request
|
Issue a POST request
|
||||||
"""
|
"""
|
||||||
|
|
||||||
response = self.client.post(url, data=data, format='json')
|
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, expected_code=None):
|
||||||
|
"""
|
||||||
|
Issue a PATCH request
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = self.client.patch(url, data=data, format='json')
|
||||||
|
|
||||||
|
if expected_code is not None:
|
||||||
|
self.assertEqual(response.status_code, expected_code)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
@ -6,12 +6,15 @@ Serializers used in various InvenTree apps
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
from rest_framework import serializers
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import User
|
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):
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
@ -39,18 +42,34 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
|
|||||||
but also ensures that the underlying model class data are checked on validation.
|
but also ensures that the underlying model class data are checked on validation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def validate(self, data):
|
def run_validation(self, data=empty):
|
||||||
""" Perform serializer validation.
|
""" Perform serializer validation.
|
||||||
In addition to running validators on the serializer fields,
|
In addition to running validators on the serializer fields,
|
||||||
this class ensures that the underlying model is also validated.
|
this class ensures that the underlying model is also validated.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Run any native validation checks first (may throw an ValidationError)
|
# Run any native validation checks first (may raise a ValidationError)
|
||||||
data = super(serializers.ModelSerializer, self).validate(data)
|
data = super().run_validation(data)
|
||||||
|
|
||||||
# Now ensure the underlying model is correct
|
# 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
|
return data
|
||||||
|
|
||||||
|
@ -80,7 +80,7 @@ def heartbeat():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from django_q.models import Success
|
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:
|
except AppRegistryNotReady:
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -105,7 +105,7 @@ def delete_successful_tasks():
|
|||||||
try:
|
try:
|
||||||
from django_q.models import Success
|
from django_q.models import Success
|
||||||
except AppRegistryNotReady:
|
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
|
return
|
||||||
|
|
||||||
threshold = datetime.now() - timedelta(days=30)
|
threshold = datetime.now() - timedelta(days=30)
|
||||||
@ -126,6 +126,7 @@ def check_for_updates():
|
|||||||
import common.models
|
import common.models
|
||||||
except AppRegistryNotReady:
|
except AppRegistryNotReady:
|
||||||
# Apps not yet loaded!
|
# Apps not yet loaded!
|
||||||
|
logger.info("Could not perform 'check_for_updates' - App registry not ready")
|
||||||
return
|
return
|
||||||
|
|
||||||
response = requests.get('https://api.github.com/repos/inventree/inventree/releases/latest')
|
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
|
from django.conf import settings
|
||||||
except AppRegistryNotReady:
|
except AppRegistryNotReady:
|
||||||
# Apps not yet loaded!
|
# Apps not yet loaded!
|
||||||
|
logger.info("Could not perform 'update_exchange_rates' - App registry not ready")
|
||||||
return
|
return
|
||||||
except:
|
except:
|
||||||
# Other error?
|
# Other error?
|
||||||
|
@ -40,7 +40,8 @@ def assign_bom_items(apps, schema_editor):
|
|||||||
except BomItem.DoesNotExist:
|
except BomItem.DoesNotExist:
|
||||||
pass
|
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):
|
def unassign_bom_items(apps, schema_editor):
|
||||||
|
@ -71,7 +71,8 @@ def migrate_currencies(apps, schema_editor):
|
|||||||
|
|
||||||
count += 1
|
count += 1
|
||||||
|
|
||||||
print(f"Updated {count} SupplierPriceBreak rows")
|
if count > 0:
|
||||||
|
print(f"Updated {count} SupplierPriceBreak rows")
|
||||||
|
|
||||||
def reverse_currencies(apps, schema_editor):
|
def reverse_currencies(apps, schema_editor):
|
||||||
"""
|
"""
|
||||||
|
@ -119,7 +119,9 @@ class ManufacturerTest(InvenTreeAPITestCase):
|
|||||||
data = {
|
data = {
|
||||||
'MPN': 'MPN-TEST-123',
|
'MPN': 'MPN-TEST-123',
|
||||||
}
|
}
|
||||||
|
|
||||||
response = self.client.patch(url, data, format='json')
|
response = self.client.patch(url, data, format='json')
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
self.assertEqual(response.data['MPN'], 'MPN-TEST-123')
|
self.assertEqual(response.data['MPN'], 'MPN-TEST-123')
|
||||||
|
|
||||||
|
@ -157,7 +157,7 @@ class POList(generics.ListCreateAPIView):
|
|||||||
ordering = '-creation_date'
|
ordering = '-creation_date'
|
||||||
|
|
||||||
|
|
||||||
class PODetail(generics.RetrieveUpdateAPIView):
|
class PODetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
""" API endpoint for detail view of a PurchaseOrder object """
|
""" API endpoint for detail view of a PurchaseOrder object """
|
||||||
|
|
||||||
queryset = PurchaseOrder.objects.all()
|
queryset = PurchaseOrder.objects.all()
|
||||||
@ -382,7 +382,7 @@ class SOList(generics.ListCreateAPIView):
|
|||||||
ordering = '-creation_date'
|
ordering = '-creation_date'
|
||||||
|
|
||||||
|
|
||||||
class SODetail(generics.RetrieveUpdateAPIView):
|
class SODetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
"""
|
"""
|
||||||
API endpoint for detail view of a SalesOrder object.
|
API endpoint for detail view of a SalesOrder object.
|
||||||
"""
|
"""
|
||||||
|
@ -93,8 +93,10 @@ class POSerializer(InvenTreeModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
read_only_fields = [
|
read_only_fields = [
|
||||||
'reference',
|
|
||||||
'status'
|
'status'
|
||||||
|
'issue_date',
|
||||||
|
'complete_date',
|
||||||
|
'creation_date',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -110,8 +112,9 @@ class POLineItemSerializer(InvenTreeModelSerializer):
|
|||||||
self.fields.pop('part_detail')
|
self.fields.pop('part_detail')
|
||||||
self.fields.pop('supplier_part_detail')
|
self.fields.pop('supplier_part_detail')
|
||||||
|
|
||||||
quantity = serializers.FloatField()
|
# TODO: Once https://github.com/inventree/InvenTree/issues/1687 is fixed, remove default values
|
||||||
received = serializers.FloatField()
|
quantity = serializers.FloatField(default=1)
|
||||||
|
received = serializers.FloatField(default=0)
|
||||||
|
|
||||||
part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True)
|
part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True)
|
||||||
supplier_part_detail = SupplierPartSerializer(source='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 = [
|
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)
|
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
|
||||||
allocations = SalesOrderAllocationSerializer(many=True, 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)
|
allocated = serializers.FloatField(source='allocated_quantity', read_only=True)
|
||||||
fulfilled = serializers.FloatField(source='fulfilled_quantity', read_only=True)
|
fulfilled = serializers.FloatField(source='fulfilled_quantity', read_only=True)
|
||||||
sale_price_string = serializers.CharField(source='sale_price', read_only=True)
|
sale_price_string = serializers.CharField(source='sale_price', read_only=True)
|
||||||
|
@ -110,6 +110,96 @@ class PurchaseOrderTest(OrderTest):
|
|||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
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):
|
class SalesOrderTest(OrderTest):
|
||||||
"""
|
"""
|
||||||
@ -158,8 +248,6 @@ class SalesOrderTest(OrderTest):
|
|||||||
|
|
||||||
response = self.get(url)
|
response = self.get(url)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
data = response.data
|
data = response.data
|
||||||
|
|
||||||
self.assertEqual(data['pk'], 1)
|
self.assertEqual(data['pk'], 1)
|
||||||
@ -168,6 +256,87 @@ class SalesOrderTest(OrderTest):
|
|||||||
|
|
||||||
url = reverse('api-so-attachment-list')
|
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)
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
name: 'M2x4 LPHS'
|
name: 'M2x4 LPHS'
|
||||||
description: 'M2x4 low profile head screw'
|
description: 'M2x4 low profile head screw'
|
||||||
category: 8
|
category: 8
|
||||||
link: www.acme.com/parts/m2x4lphs
|
link: http://www.acme.com/parts/m2x4lphs
|
||||||
tree_id: 0
|
tree_id: 0
|
||||||
purchaseable: True
|
purchaseable: True
|
||||||
level: 0
|
level: 0
|
||||||
@ -56,6 +56,7 @@
|
|||||||
fields:
|
fields:
|
||||||
name: 'C_22N_0805'
|
name: 'C_22N_0805'
|
||||||
description: '22nF capacitor in 0805 package'
|
description: '22nF capacitor in 0805 package'
|
||||||
|
purchaseable: true
|
||||||
category: 3
|
category: 3
|
||||||
tree_id: 0
|
tree_id: 0
|
||||||
level: 0
|
level: 0
|
||||||
|
@ -71,7 +71,8 @@ def migrate_currencies(apps, schema_editor):
|
|||||||
|
|
||||||
count += 1
|
count += 1
|
||||||
|
|
||||||
print(f"Updated {count} SupplierPriceBreak rows")
|
if count > 0:
|
||||||
|
print(f"Updated {count} SupplierPriceBreak rows")
|
||||||
|
|
||||||
def reverse_currencies(apps, schema_editor):
|
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 = _("Part")
|
||||||
verbose_name_plural = _("Parts")
|
verbose_name_plural = _("Parts")
|
||||||
ordering = ['name', ]
|
ordering = ['name', ]
|
||||||
|
constraints = [
|
||||||
|
UniqueConstraint(fields=['name', 'IPN', 'revision'], name='unique_part')
|
||||||
|
]
|
||||||
|
|
||||||
class MPTTMeta:
|
class MPTTMeta:
|
||||||
# For legacy reasons the 'variant_of' field is used to indicate the MPTT parent
|
# 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}'")
|
logger.info(f"Deleting unused image file '{previous.image}'")
|
||||||
previous.image.delete(save=False)
|
previous.image.delete(save=False)
|
||||||
|
|
||||||
self.clean()
|
self.full_clean()
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
@ -642,23 +645,6 @@ class Part(MPTTModel):
|
|||||||
'IPN': _('Duplicate IPN not allowed in part settings'),
|
'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):
|
def clean(self):
|
||||||
"""
|
"""
|
||||||
Perform cleaning operations for the Part model
|
Perform cleaning operations for the Part model
|
||||||
@ -671,8 +657,6 @@ class Part(MPTTModel):
|
|||||||
|
|
||||||
super().clean()
|
super().clean()
|
||||||
|
|
||||||
self.validate_unique()
|
|
||||||
|
|
||||||
if self.trackable:
|
if self.trackable:
|
||||||
for part in self.get_used_in().all():
|
for part in self.get_used_in().all():
|
||||||
|
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from part.models import Part
|
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
|
||||||
|
|
||||||
@ -230,6 +232,18 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
response = self.client.get(url, data={'part': 10004})
|
response = self.client.get(url, data={'part': 10004})
|
||||||
self.assertEqual(len(response.data), 7)
|
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)
|
# Try to post a new object (should succeed)
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
url,
|
url,
|
||||||
@ -237,6 +251,7 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
'part': 10000,
|
'part': 10000,
|
||||||
'test_name': 'New Test',
|
'test_name': 'New Test',
|
||||||
'required': True,
|
'required': True,
|
||||||
|
'description': 'a test description'
|
||||||
},
|
},
|
||||||
format='json',
|
format='json',
|
||||||
)
|
)
|
||||||
@ -248,7 +263,8 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
url,
|
url,
|
||||||
data={
|
data={
|
||||||
'part': 10004,
|
'part': 10004,
|
||||||
'test_name': " newtest"
|
'test_name': " newtest",
|
||||||
|
'description': 'dafsdf',
|
||||||
},
|
},
|
||||||
format='json',
|
format='json',
|
||||||
)
|
)
|
||||||
@ -293,6 +309,171 @@ class PartAPITest(InvenTreeAPITestCase):
|
|||||||
self.assertEqual(len(data['results']), n)
|
self.assertEqual(len(data['results']), n)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
class PartAPIAggregationTest(InvenTreeAPITestCase):
|
class PartAPIAggregationTest(InvenTreeAPITestCase):
|
||||||
"""
|
"""
|
||||||
Tests to ensure that the various aggregation annotations are working correctly...
|
Tests to ensure that the various aggregation annotations are working correctly...
|
||||||
@ -319,6 +500,8 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
|
|||||||
# Add a new part
|
# Add a new part
|
||||||
self.part = Part.objects.create(
|
self.part = Part.objects.create(
|
||||||
name='Banana',
|
name='Banana',
|
||||||
|
description='This is a banana',
|
||||||
|
category=PartCategory.objects.get(pk=1),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create some stock items associated with the part
|
# Create some stock items associated with the part
|
||||||
|
@ -11,7 +11,7 @@ from django.core.exceptions import ValidationError
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from .models import Part, PartTestTemplate
|
from .models import Part, PartCategory, PartTestTemplate
|
||||||
from .models import rename_part_image, match_part_names
|
from .models import rename_part_image, match_part_names
|
||||||
from .templatetags import inventree_extras
|
from .templatetags import inventree_extras
|
||||||
|
|
||||||
@ -78,6 +78,61 @@ class PartTest(TestCase):
|
|||||||
p = Part.objects.get(pk=100)
|
p = Part.objects.get(pk=100)
|
||||||
self.assertEqual(str(p), "BOB | Bob | A2 - Can we build it?")
|
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):
|
def test_metadata(self):
|
||||||
self.assertEqual(self.r1.name, 'R_2K2_0805')
|
self.assertEqual(self.r1.name, 'R_2K2_0805')
|
||||||
self.assertEqual(self.r1.get_absolute_url(), '/part/3/')
|
self.assertEqual(self.r1.get_absolute_url(), '/part/3/')
|
||||||
@ -277,21 +332,24 @@ class PartSettingsTest(TestCase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Create a part
|
# 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)
|
# Attempt to create a duplicate item (should fail)
|
||||||
with self.assertRaises(ValidationError):
|
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)
|
# Attempt to create item with duplicate IPN (should be allowed by default)
|
||||||
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='B')
|
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='B')
|
||||||
|
|
||||||
# And attempt again with the same values (should fail)
|
# And attempt again with the same values (should fail)
|
||||||
with self.assertRaises(ValidationError):
|
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
|
# Now update the settings so duplicate IPN values are *not* allowed
|
||||||
InvenTreeSetting.set_setting('PART_ALLOW_DUPLICATE_IPN', False, self.user)
|
InvenTreeSetting.set_setting('PART_ALLOW_DUPLICATE_IPN', False, self.user)
|
||||||
|
|
||||||
with self.assertRaises(ValidationError):
|
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()
|
||||||
|
@ -199,7 +199,8 @@ def update_history(apps, schema_editor):
|
|||||||
update_count += 1
|
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):
|
def reverse_update(apps, schema_editor):
|
||||||
|
@ -26,7 +26,8 @@ def extract_purchase_price(apps, schema_editor):
|
|||||||
# Find all the StockItem objects without a purchase_price which point to a PurchaseOrder
|
# 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)
|
items = StockItem.objects.filter(purchase_price=None).exclude(purchase_order=None)
|
||||||
|
|
||||||
print(f"Found {items.count()} stock items with missing purchase price information")
|
if items.count() > 0:
|
||||||
|
print(f"Found {items.count()} stock items with missing purchase price information")
|
||||||
|
|
||||||
update_count = 0
|
update_count = 0
|
||||||
|
|
||||||
@ -56,7 +57,8 @@ def extract_purchase_price(apps, schema_editor):
|
|||||||
|
|
||||||
break
|
break
|
||||||
|
|
||||||
print(f"Updated pricing for {update_count} stock items")
|
if update_count > 0:
|
||||||
|
print(f"Updated pricing for {update_count} stock items")
|
||||||
|
|
||||||
def reverse_operation(apps, schema_editor):
|
def reverse_operation(apps, schema_editor):
|
||||||
"""
|
"""
|
||||||
|
Loading…
Reference in New Issue
Block a user