2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-19 21:45:39 +00:00

Merge branch 'inventree:master' into bpm-purchase-price

This commit is contained in:
Matthias Mair
2021-08-20 00:42:50 +02:00
committed by GitHub
47 changed files with 1187 additions and 455 deletions

View File

@ -9,12 +9,14 @@ from django.conf.urls import url, include
from django.urls import reverse
from django.http import JsonResponse
from django.db.models import Q, F, Count, Min, Max, Avg
from django.db import transaction
from django.utils.translation import ugettext_lazy as _
from rest_framework import status
from rest_framework.response import Response
from rest_framework import filters, serializers
from rest_framework import generics
from rest_framework.exceptions import ValidationError
from django_filters.rest_framework import DjangoFilterBackend
from django_filters import rest_framework as rest_filters
@ -23,7 +25,7 @@ from djmoney.money import Money
from djmoney.contrib.exchange.models import convert_money
from djmoney.contrib.exchange.exceptions import MissingRate
from decimal import Decimal
from decimal import Decimal, InvalidOperation
from .models import Part, PartCategory, BomItem
from .models import PartParameter, PartParameterTemplate
@ -31,7 +33,10 @@ from .models import PartAttachment, PartTestTemplate
from .models import PartSellPriceBreak, PartInternalPriceBreak
from .models import PartCategoryParameterTemplate
from stock.models import StockItem
from company.models import Company, ManufacturerPart, SupplierPart
from stock.models import StockItem, StockLocation
from common.models import InvenTreeSetting
from build.models import Build
@ -630,6 +635,7 @@ class PartList(generics.ListCreateAPIView):
else:
return Response(data)
@transaction.atomic
def create(self, request, *args, **kwargs):
"""
We wish to save the user who created this part!
@ -637,6 +643,8 @@ class PartList(generics.ListCreateAPIView):
Note: Implementation copied from DRF class CreateModelMixin
"""
# TODO: Unit tests for this function!
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
@ -680,21 +688,97 @@ class PartList(generics.ListCreateAPIView):
pass
# Optionally create initial stock item
try:
initial_stock = Decimal(request.data.get('initial_stock', 0))
initial_stock = str2bool(request.data.get('initial_stock', False))
if initial_stock > 0 and part.default_location is not None:
if initial_stock:
try:
stock_item = StockItem(
initial_stock_quantity = Decimal(request.data.get('initial_stock_quantity', ''))
if initial_stock_quantity <= 0:
raise ValidationError({
'initial_stock_quantity': [_('Must be greater than zero')],
})
except (ValueError, InvalidOperation): # Invalid quantity provided
raise ValidationError({
'initial_stock_quantity': [_('Must be a valid quantity')],
})
initial_stock_location = request.data.get('initial_stock_location', None)
try:
initial_stock_location = StockLocation.objects.get(pk=initial_stock_location)
except (ValueError, StockLocation.DoesNotExist):
initial_stock_location = None
if initial_stock_location is None:
if part.default_location is not None:
initial_stock_location = part.default_location
else:
raise ValidationError({
'initial_stock_location': [_('Specify location for initial part stock')],
})
stock_item = StockItem(
part=part,
quantity=initial_stock_quantity,
location=initial_stock_location,
)
stock_item.save(user=request.user)
# Optionally add manufacturer / supplier data to the part
if part.purchaseable and str2bool(request.data.get('add_supplier_info', False)):
try:
manufacturer = Company.objects.get(pk=request.data.get('manufacturer', None))
except:
manufacturer = None
try:
supplier = Company.objects.get(pk=request.data.get('supplier', None))
except:
supplier = None
mpn = str(request.data.get('MPN', '')).strip()
sku = str(request.data.get('SKU', '')).strip()
# Construct a manufacturer part
if manufacturer or mpn:
if not manufacturer:
raise ValidationError({
'manufacturer': [_("This field is required")]
})
if not mpn:
raise ValidationError({
'MPN': [_("This field is required")]
})
manufacturer_part = ManufacturerPart.objects.create(
part=part,
quantity=initial_stock,
location=part.default_location,
manufacturer=manufacturer,
MPN=mpn
)
else:
# No manufacturer part data specified
manufacturer_part = None
stock_item.save(user=request.user)
if supplier or sku:
if not supplier:
raise ValidationError({
'supplier': [_("This field is required")]
})
if not sku:
raise ValidationError({
'SKU': [_("This field is required")]
})
except:
pass
SupplierPart.objects.create(
part=part,
supplier=supplier,
SKU=sku,
manufacturer_part=manufacturer_part,
)
headers = self.get_success_headers(serializer.data)

View File

@ -1,6 +1,7 @@
"""
JSON serializers for Part app
"""
import imghdr
from decimal import Decimal
@ -16,7 +17,9 @@ from djmoney.contrib.django_rest_framework import MoneyField
from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
InvenTreeImageSerializerField,
InvenTreeModelSerializer,
InvenTreeAttachmentSerializer,
InvenTreeMoneySerializer)
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
from stock.models import StockItem
@ -51,7 +54,7 @@ class CategorySerializer(InvenTreeModelSerializer):
]
class PartAttachmentSerializer(InvenTreeModelSerializer):
class PartAttachmentSerializer(InvenTreeAttachmentSerializer):
"""
Serializer for the PartAttachment class
"""
@ -65,6 +68,7 @@ class PartAttachmentSerializer(InvenTreeModelSerializer):
'pk',
'part',
'attachment',
'filename',
'comment',
'upload_date',
]

View File

@ -132,12 +132,13 @@
</button>
{% endif %}
<div class='btn-group'>
<button id='part-options' class='btn btn-primary dropdown-toggle' type='button' data-toggle="dropdown">{% trans "Options" %}<span class='caret'></span></button>
<button id='part-options' class='btn btn-primary dropdown-toggle' type='button' data-toggle="dropdown">{% trans "Options" %} <span class='caret'></span></button>
<ul class='dropdown-menu'>
{% if roles.part.change %}
<li><a href='#' id='multi-part-category' title='{% trans "Set category" %}'>{% trans "Set Category" %}</a></li>
{% endif %}
<li><a href='#' id='multi-part-order' title='{% trans "Order parts" %}'>{% trans "Order Parts" %}</a></li>
<li><a href='#' id='multi-part-print-label' title='{% trans "Print Labels" %}'>{% trans "Print Labels" %}</a></li>
<li><a href='#' id='multi-part-export' title='{% trans "Export" %}'>{% trans "Export Data" %}</a></li>
</ul>
</div>
@ -276,6 +277,7 @@
constructForm('{% url "api-part-list" %}', {
method: 'POST',
fields: fields,
groups: partGroups(),
title: '{% trans "Create Part" %}',
onSuccess: function(data) {
// Follow the new part
@ -336,4 +338,4 @@
default: 'part-stock'
});
{% endblock %}
{% endblock %}

View File

@ -289,7 +289,7 @@
<span class='fas fa-plus-circle'></span> {% trans "New Supplier Part" %}
</button>
<div id='opt-dropdown' class="btn-group">
<button id='supplier-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
<button id='supplier-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %} <span class="caret"></span></button>
<ul class="dropdown-menu">
<li><a href='#' id='supplier-part-delete' title='{% trans "Delete supplier parts" %}'>{% trans "Delete" %}</a></li>
</ul>
@ -312,7 +312,7 @@
<span class='fas fa-plus-circle'></span> {% trans "New Manufacturer Part" %}
</button>
<div id='opt-dropdown' class="btn-group">
<button id='manufacturer-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
<button id='manufacturer-part-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %} <span class="caret"></span></button>
<ul class="dropdown-menu">
<li><a href='#' id='manufacturer-part-delete' title='{% trans "Delete manufacturer parts" %}'>{% trans "Delete" %}</a></li>
</ul>
@ -868,6 +868,7 @@
constructForm(url, {
fields: {
filename: {},
comment: {},
},
title: '{% trans "Edit Attachment" %}',

View File

@ -1,8 +1,24 @@
{% extends "base.html" %}
{% extends "part/part_app_base.html" %}
{% load inventree_extras %}
{% load i18n %}
{% load static %}
{% block menubar %}
<ul class='list-group'>
<li class='list-group-item'>
<a href='#' id='part-menu-toggle'>
<span class='menu-tab-icon fas fa-expand-arrows-alt'></span>
</a>
</li>
<li class='list-group-item' title='{% trans "Return To Parts" %}'>
<a href='{% url "part-index" %}' id='select-upload-file' class='nav-toggle'>
<span class='fas fa-undo side-icon'></span>
{% trans "Return To Parts" %}
</a>
</li>
</ul>
{% endblock %}
{% block content %}
<div class='panel panel-default panel-inventree'>
<div class='panel-heading'>
@ -54,4 +70,9 @@
{% block js_ready %}
{{ block.super }}
enableNavbar({
label: 'part',
toggleId: '#part-menu-toggle',
});
{% endblock %}

View File

@ -6,6 +6,7 @@ over and above the built-in Django tags.
import os
import sys
from django.utils.html import format_html
from django.utils.translation import ugettext_lazy as _
from django.conf import settings as djangosettings
@ -262,6 +263,26 @@ def get_available_themes(*args, **kwargs):
return themes
@register.simple_tag()
def primitive_to_javascript(primitive):
"""
Convert a python primitive to a javascript primitive.
e.g. True -> true
'hello' -> '"hello"'
"""
if type(primitive) is bool:
return str(primitive).lower()
elif type(primitive) in [int, float]:
return primitive
else:
# Wrap with quotes
return format_html("'{}'", primitive)
@register.filter
def keyvalue(dict, key):
"""

View File

@ -129,6 +129,7 @@ class PartAPITest(InvenTreeAPITestCase):
'location',
'bom',
'test_templates',
'company',
]
roles = [
@ -465,6 +466,128 @@ class PartAPITest(InvenTreeAPITestCase):
self.assertFalse(response.data['active'])
self.assertFalse(response.data['purchaseable'])
def test_initial_stock(self):
"""
Tests for initial stock quantity creation
"""
url = reverse('api-part-list')
# Track how many parts exist at the start of this test
n = Part.objects.count()
# Set up required part data
data = {
'category': 1,
'name': "My lil' test part",
'description': 'A part with which to test',
}
# Signal that we want to add initial stock
data['initial_stock'] = True
# Post without a quantity
response = self.post(url, data, expected_code=400)
self.assertIn('initial_stock_quantity', response.data)
# Post with an invalid quantity
data['initial_stock_quantity'] = "ax"
response = self.post(url, data, expected_code=400)
self.assertIn('initial_stock_quantity', response.data)
# Post with a negative quantity
data['initial_stock_quantity'] = -1
response = self.post(url, data, expected_code=400)
self.assertIn('Must be greater than zero', response.data['initial_stock_quantity'])
# Post with a valid quantity
data['initial_stock_quantity'] = 12345
response = self.post(url, data, expected_code=400)
self.assertIn('initial_stock_location', response.data)
# Check that the number of parts has not increased (due to form failures)
self.assertEqual(Part.objects.count(), n)
# Now, set a location
data['initial_stock_location'] = 1
response = self.post(url, data, expected_code=201)
# Check that the part has been created
self.assertEqual(Part.objects.count(), n + 1)
pk = response.data['pk']
new_part = Part.objects.get(pk=pk)
self.assertEqual(new_part.total_stock, 12345)
def test_initial_supplier_data(self):
"""
Tests for initial creation of supplier / manufacturer data
"""
url = reverse('api-part-list')
n = Part.objects.count()
# Set up initial part data
data = {
'category': 1,
'name': 'Buy Buy Buy',
'description': 'A purchaseable part',
'purchaseable': True,
}
# Signal that we wish to create initial supplier data
data['add_supplier_info'] = True
# Specify MPN but not manufacturer
data['MPN'] = 'MPN-123'
response = self.post(url, data, expected_code=400)
self.assertIn('manufacturer', response.data)
# Specify manufacturer but not MPN
del data['MPN']
data['manufacturer'] = 1
response = self.post(url, data, expected_code=400)
self.assertIn('MPN', response.data)
# Specify SKU but not supplier
del data['manufacturer']
data['SKU'] = 'SKU-123'
response = self.post(url, data, expected_code=400)
self.assertIn('supplier', response.data)
# Specify supplier but not SKU
del data['SKU']
data['supplier'] = 1
response = self.post(url, data, expected_code=400)
self.assertIn('SKU', response.data)
# Check that no new parts have been created
self.assertEqual(Part.objects.count(), n)
# Now, fully specify the details
data['SKU'] = 'SKU-123'
data['supplier'] = 3
data['MPN'] = 'MPN-123'
data['manufacturer'] = 6
response = self.post(url, data, expected_code=201)
self.assertEqual(Part.objects.count(), n + 1)
pk = response.data['pk']
new_part = Part.objects.get(pk=pk)
# Check that there is a new manufacturer part *and* a new supplier part
self.assertEqual(new_part.supplier_parts.count(), 1)
self.assertEqual(new_part.manufacturer_parts.count(), 1)
class PartDetailTests(InvenTreeAPITestCase):
"""

View File

@ -2,6 +2,8 @@
Unit testing for BOM export functionality
"""
import csv
from django.test import TestCase
from django.urls import reverse
@ -47,13 +49,63 @@ class BomExportTest(TestCase):
self.url = reverse('bom-download', kwargs={'pk': 100})
def test_bom_template(self):
"""
Test that the BOM template can be downloaded from the server
"""
url = reverse('bom-upload-template')
# Download an XLS template
response = self.client.get(url, data={'format': 'xls'})
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.headers['Content-Disposition'],
'attachment; filename="InvenTree_BOM_Template.xls"'
)
# Return a simple CSV template
response = self.client.get(url, data={'format': 'csv'})
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.headers['Content-Disposition'],
'attachment; filename="InvenTree_BOM_Template.csv"'
)
filename = '_tmp.csv'
with open(filename, 'wb') as f:
f.write(response.getvalue())
with open(filename, 'r') as f:
reader = csv.reader(f, delimiter=',')
for line in reader:
headers = line
break
expected = [
'part_id',
'part_ipn',
'part_name',
'quantity',
'optional',
'overage',
'reference',
'note',
'inherited',
'allow_variants',
]
# Ensure all the expected headers are in the provided file
for header in expected:
self.assertTrue(header in headers)
def test_export_csv(self):
"""
Test BOM download in CSV format
"""
print("URL", self.url)
params = {
'file_format': 'csv',
'cascade': True,
@ -70,6 +122,47 @@ class BomExportTest(TestCase):
content = response.headers['Content-Disposition']
self.assertEqual(content, 'attachment; filename="BOB | Bob | A2_BOM.csv"')
filename = '_tmp.csv'
with open(filename, 'wb') as f:
f.write(response.getvalue())
# Read the file
with open(filename, 'r') as f:
reader = csv.reader(f, delimiter=',')
for line in reader:
headers = line
break
expected = [
'level',
'bom_id',
'parent_part_id',
'parent_part_ipn',
'parent_part_name',
'part_id',
'part_ipn',
'part_name',
'part_description',
'sub_assembly',
'quantity',
'optional',
'overage',
'reference',
'note',
'inherited',
'allow_variants',
'Default Location',
'Available Stock',
]
for header in expected:
self.assertTrue(header in headers)
for header in headers:
self.assertTrue(header in expected)
def test_export_xls(self):
"""
Test BOM download in XLS format