Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2019-09-13 23:38:07 +10:00
commit 4b33b15dd2
14 changed files with 454 additions and 17 deletions

View File

@ -227,6 +227,10 @@ USE_L10N = True
USE_TZ = True
DATE_INPUT_FORMATS = [
"%Y-%m-%d",
]
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.10/howto/static-files/

View File

@ -137,6 +137,10 @@ function loadPartTable(table, url, options={}) {
var display = imageHoverIcon(row.image) + renderLink(name, '/part/' + row.pk + '/');
if (row.is_template) {
display = display + "<span class='label label-info' style='float: right;'>TEMPLATE</span>";
}
if (!row.active) {
display = display + "<span class='label label-warning' style='float: right;'>INACTIVE</span>";
}

View File

@ -1,9 +1,14 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.contrib import admin
from import_export.admin import ImportExportModelAdmin
from .models import Currency
class CurrencyAdmin(admin.ModelAdmin):
class CurrencyAdmin(ImportExportModelAdmin):
list_display = ('symbol', 'suffix', 'description', 'value', 'base')

View File

@ -1,20 +1,74 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.contrib import admin
from import_export.admin import ImportExportModelAdmin
from import_export.resources import ModelResource
from import_export.fields import Field
import import_export.widgets as widgets
from .models import Company
from .models import SupplierPart
from .models import SupplierPriceBreak
from part.models import Part
from common.models import Currency
class CompanyResource(ModelResource):
""" Class for managing Company data import/export """
class Meta:
model = Company
skip_unchanged = True
report_skipped = False
class CompanyAdmin(ImportExportModelAdmin):
resource_class = CompanyResource
list_display = ('name', 'website', 'contact')
class SupplierPartResource(ModelResource):
""" Class for managing SupplierPart data import/export """
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
supplier = Field(attribute='supplier', widget=widgets.ForeignKeyWidget(Company))
class Meta:
model = SupplierPart
skip_unchanged = True
report_skipped = False
class SupplierPartAdmin(ImportExportModelAdmin):
resource_class = SupplierPartResource
list_display = ('part', 'supplier', 'SKU')
class SupplierPriceBreakResource(ModelResource):
""" Class for managing SupplierPriceBreak data import/export """
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(SupplierPart))
currency = Field(attribute='currency', widget=widgets.ForeignKeyWidget(Currency))
class Meta:
model = SupplierPriceBreak
skip_unchanged = True
report_skipped = False
class SupplierPriceBreakAdmin(ImportExportModelAdmin):
resource_class = SupplierPriceBreakResource
list_display = ('part', 'quantity', 'cost')

View File

@ -15,6 +15,13 @@
supplier: 1
SKU: 'ACME0002'
- model: company.supplierpart
pk: 3
fields:
part: 1
supplier: 1
SKU: 'ACME0003'
# Widget purchaseable from ACME
- model: company.supplierpart
pk: 100
@ -33,7 +40,7 @@
# M2x4 LPHS from Zerg Corp
- model: company.supplierpart
pk: 3
pk: 7
fields:
part: 1
supplier: 3

View File

@ -56,7 +56,7 @@ class CompanySimpleTest(TestCase):
zerg = Company.objects.get(pk=3)
self.assertTrue(acme.has_parts)
self.assertEqual(acme.part_count, 3)
self.assertEqual(acme.part_count, 4)
self.assertTrue(appel.has_parts)
self.assertEqual(appel.part_count, 2)

View File

@ -2,11 +2,12 @@
from __future__ import unicode_literals
from django.contrib import admin
from import_export.admin import ImportExportModelAdmin
from .models import PurchaseOrder, PurchaseOrderLineItem
class PurchaseOrderAdmin(admin.ModelAdmin):
class PurchaseOrderAdmin(ImportExportModelAdmin):
list_display = (
'reference',
@ -17,7 +18,7 @@ class PurchaseOrderAdmin(admin.ModelAdmin):
)
class PurchaseOrderLineItemAdmin(admin.ModelAdmin):
class PurchaseOrderLineItemAdmin(ImportExportModelAdmin):
list_display = (
'order',

View File

@ -35,8 +35,16 @@
quantity: 250
received: 50
# 1000 x ACME0003
- model: order.purchaseorderlineitem
fields:
order: 1
part: 3
quantity: 1000
# 100 x ZERGLPHS (M2x4 LPHS)
- model: order.purchaseorderlineitem
pk: 22
fields:
order: 2
part: 3

View File

@ -5,6 +5,7 @@ Order model definitions
# -*- coding: utf-8 -*-
from django.db import models, transaction
from django.db.models import F
from django.core.validators import MinValueValidator
from django.core.exceptions import ValidationError
from django.contrib.auth.models import User
@ -226,7 +227,7 @@ class PurchaseOrder(Order):
Any line item where 'received' < 'quantity' will be returned.
"""
return [line for line in self.lines.all() if line.quantity > line.received]
return self.lines.filter(quantity__gt=F('received'))
@transaction.atomic
def receive_line_item(self, line, location, quantity, user):

View File

@ -4,6 +4,12 @@ from django.test import TestCase
from django.urls import reverse
from django.contrib.auth import get_user_model
from InvenTree.status_codes import OrderStatus
from .models import PurchaseOrder, PurchaseOrderLineItem
import json
class OrderViewTestCase(TestCase):
@ -75,3 +81,106 @@ class POTests(OrderViewTestCase):
# Response should be streaming-content (file download)
self.assertIn('streaming_content', dir(response))
def test_po_issue(self):
""" Test PurchaseOrderIssue view """
url = reverse('purchase-order-issue', args=(1,))
order = PurchaseOrder.objects.get(pk=1)
self.assertEqual(order.status, OrderStatus.PENDING)
# Test without confirmation
response = self.client.post(url, {'confirm': 0}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertFalse(data['form_valid'])
# Test WITH confirmation
response = self.client.post(url, {'confirm': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertTrue(data['form_valid'])
# Test that the order was actually placed
order = PurchaseOrder.objects.get(pk=1)
self.assertEqual(order.status, OrderStatus.PLACED)
def test_line_item_create(self):
""" Test the form for adding a new LineItem to a PurchaseOrder """
# Record the number of line items in the PurchaseOrder
po = PurchaseOrder.objects.get(pk=1)
n = po.lines.count()
self.assertEqual(po.status, OrderStatus.PENDING)
url = reverse('po-line-item-create')
# GET the form (pass the correct info)
response = self.client.get(url, {'order': 1}, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
post_data = {
'part': 100,
'quantity': 45,
'reference': 'Test reference field',
'notes': 'Test notes field'
}
# POST with an invalid purchase order
post_data['order'] = 99
response = self.client.post(url, post_data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
data = json.loads(response.content)
self.assertFalse(data['form_valid'])
self.assertIn('Invalid Purchase Order', str(data['html_form']))
# POST with a part that does not match the purchase order
post_data['order'] = 1
post_data['part'] = 7
response = self.client.post(url, post_data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
data = json.loads(response.content)
self.assertFalse(data['form_valid'])
self.assertIn('must match for Part and Order', str(data['html_form']))
# POST with an invalid part
post_data['part'] = 12345
response = self.client.post(url, post_data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
data = json.loads(response.content)
self.assertFalse(data['form_valid'])
self.assertIn('Invalid SupplierPart selection', str(data['html_form']))
# POST the form with valid data
post_data['part'] = 100
response = self.client.post(url, post_data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertTrue(data['form_valid'])
self.assertEqual(n + 1, PurchaseOrder.objects.get(pk=1).lines.count())
line = PurchaseOrderLineItem.objects.get(order=1, part=100)
self.assertEqual(line.quantity, 45)
def test_line_item_edit(self):
""" Test editing form for PO line item """
url = reverse('po-line-item-edit', args=(22,))
response = self.client.get(url, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
class TestPOReceive(OrderViewTestCase):
""" Tests for receiving a purchase order """
def setUp(self):
super().setUp()
self.po = PurchaseOrder.objects.get(pk=1)
self.url = reverse('purchase-order-receive', args=(1,))
def test_receive_lines(self):
# TODO
pass

View File

@ -47,10 +47,10 @@ class OrderTest(TestCase):
for supplier in part.supplier_parts.all():
open_orders += supplier.open_orders()
self.assertEqual(len(open_orders), 3)
self.assertEqual(len(open_orders), 4)
# Test the total on-order quantity
self.assertEqual(part.on_order, 400)
self.assertEqual(part.on_order, 1400)
def test_add_items(self):
""" Test functions for adding line items to an order """
@ -58,7 +58,7 @@ class OrderTest(TestCase):
order = PurchaseOrder.objects.get(pk=1)
self.assertEqual(order.status, OrderStatus.PENDING)
self.assertEqual(order.lines.count(), 2)
self.assertEqual(order.lines.count(), 3)
sku = SupplierPart.objects.get(SKU='ACME-WIDGET')
part = sku.part
@ -76,11 +76,11 @@ class OrderTest(TestCase):
order.add_line_item(sku, 100)
self.assertEqual(part.on_order, 100)
self.assertEqual(order.lines.count(), 3)
self.assertEqual(order.lines.count(), 4)
# Order the same part again (it should be merged)
order.add_line_item(sku, 50)
self.assertEqual(order.lines.count(), 3)
self.assertEqual(order.lines.count(), 4)
self.assertEqual(part.on_order, 150)
# Try to order a supplier part from the wrong supplier
@ -101,7 +101,7 @@ class OrderTest(TestCase):
loc = StockLocation.objects.get(id=1)
# There should be two lines against this order
self.assertEqual(len(order.pending_line_items()), 2)
self.assertEqual(len(order.pending_line_items()), 3)
# Should fail, as order is 'PENDING' not 'PLACED"
self.assertEqual(order.status, OrderStatus.PENDING)
@ -117,7 +117,7 @@ class OrderTest(TestCase):
self.assertEqual(line.remaining(), 50)
self.assertEqual(part.on_order, 350)
self.assertEqual(part.on_order, 1350)
# Try to order some invalid things
with self.assertRaises(django_exceptions.ValidationError):
@ -132,7 +132,12 @@ class OrderTest(TestCase):
line = PurchaseOrderLineItem.objects.get(id=2)
order.receive_line_item(line, loc, 2 * line.quantity, user=None)
self.assertEqual(part.on_order, 100)
self.assertEqual(part.on_order, 1100)
self.assertEqual(order.status, OrderStatus.PLACED)
for line in order.pending_line_items():
order.receive_line_item(line, loc, line.quantity, user=None)
self.assertEqual(order.status, OrderStatus.COMPLETE)
def test_export(self):

View File

@ -243,6 +243,11 @@ class PurchaseOrderReceive(AjaxView):
except (PurchaseOrderLineItem.DoesNotExist, ValueError):
continue
# Check that line matches the order
if not line.order == self.order:
# TODO - Display a non-field error?
continue
# Ignore a part that doesn't map to a SupplierPart
try:
if line.part is None:
@ -277,6 +282,7 @@ class PurchaseOrderReceive(AjaxView):
return self.renderJsonResponse(request, data=data)
@transaction.atomic
def receive_parts(self):
""" Called once the form has been validated.
Create new stockitems against received parts.
@ -611,13 +617,30 @@ class POLineItemCreate(AjaxCreateView):
valid = form.is_valid()
# Extract the SupplierPart ID from the form
part_id = form['part'].value()
# Extract the Order ID from the form
order_id = form['order'].value()
try:
SupplierPart.objects.get(id=part_id)
order = PurchaseOrder.objects.get(id=order_id)
except (ValueError, PurchaseOrder.DoesNotExist):
order = None
form.errors['order'] = [_('Invalid Purchase Order')]
valid = False
try:
sp = SupplierPart.objects.get(id=part_id)
if order is not None:
if not sp.supplier == order.supplier:
form.errors['part'] = [_('Supplier must match for Part and Order')]
valid = False
except (SupplierPart.DoesNotExist, ValueError):
valid = False
form.errors['part'] = [_('This field is required')]
form.errors['part'] = [_('Invalid SupplierPart selection')]
data = {
'form_valid': valid,
@ -639,6 +662,11 @@ class POLineItemCreate(AjaxCreateView):
form = super().get_form()
# Limit the available to orders to ones that are PENDING
query = form.fields['order'].queryset
query = query.filter(status=OrderStatus.PENDING)
form.fields['order'].queryset = query
order_id = form['order'].value()
try:
@ -660,7 +688,7 @@ class POLineItemCreate(AjaxCreateView):
form.fields['part'].queryset = query
form.fields['order'].widget = HiddenInput()
except PurchaseOrder.DoesNotExist:
except (ValueError, PurchaseOrder.DoesNotExist):
pass
return form

View File

@ -1,21 +1,116 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.contrib import admin
from import_export.admin import ImportExportModelAdmin
from import_export.resources import ModelResource
from import_export.fields import Field
import import_export.widgets as widgets
from .models import PartCategory, Part
from .models import PartAttachment, PartStar
from .models import BomItem
from .models import PartParameterTemplate, PartParameter
from stock.models import StockLocation
from company.models import SupplierPart
class PartResource(ModelResource):
""" Class for managing Part data import/export """
# ForeignKey fields
category = Field(attribute='category', widget=widgets.ForeignKeyWidget(PartCategory))
default_location = Field(attribute='default_location', widget=widgets.ForeignKeyWidget(StockLocation))
default_supplier = Field(attribute='default_supplier', widget=widgets.ForeignKeyWidget(SupplierPart))
category_name = Field(attribute='category__name', readonly=True)
variant_of = Field(attribute='variant_of', widget=widgets.ForeignKeyWidget(Part))
# Extra calculated meta-data (readonly)
in_stock = Field(attribute='total_stock', readonly=True, widget=widgets.IntegerWidget())
on_order = Field(attribute='on_order', readonly=True, widget=widgets.IntegerWidget())
used_in = Field(attribute='used_in_count', readonly=True, widget=widgets.IntegerWidget())
allocated = Field(attribute='allocation_count', readonly=True, widget=widgets.IntegerWidget())
building = Field(attribute='quantity_being_built', readonly=True, widget=widgets.IntegerWidget())
class Meta:
model = Part
skip_unchanged = True
report_skipped = False
exclude = [
'bom_checksum', 'bom_checked_by', 'bom_checked_date'
]
def get_queryset(self):
""" Prefetch related data for quicker access """
query = super().get_queryset()
query = query.prefetch_related(
'category',
'used_in',
'builds',
'supplier_parts__purchase_order_line_items',
'stock_items__allocations'
)
return query
class PartAdmin(ImportExportModelAdmin):
resource_class = PartResource
list_display = ('full_name', 'description', 'total_stock', 'category')
list_filter = ('active', 'assembly', 'is_template', 'virtual')
search_fields = ('name', 'description', 'category__name', 'category__description', 'IPN')
class PartCategoryResource(ModelResource):
""" Class for managing PartCategory data import/export """
parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(PartCategory))
parent_name = Field(attribute='parent__name', readonly=True)
default_location = Field(attribute='default_location', widget=widgets.ForeignKeyWidget(StockLocation))
class Meta:
model = PartCategory
skip_unchanged = True
report_skipped = False
exclude = [
# Exclude MPTT internal model fields
'lft', 'rght', 'tree_id', 'level',
]
def after_import(self, dataset, result, using_transactions, dry_run, **kwargs):
super().after_import(dataset, result, using_transactions, dry_run, **kwargs)
# Rebuild the PartCategory tree(s)
PartCategory.objects.rebuild()
class PartCategoryAdmin(ImportExportModelAdmin):
resource_class = PartCategoryResource
list_display = ('name', 'pathstring', 'description')
search_fields = ('name', 'description')
class PartAttachmentAdmin(admin.ModelAdmin):
@ -27,15 +122,53 @@ class PartStarAdmin(admin.ModelAdmin):
list_display = ('part', 'user')
class BomItemResource(ModelResource):
""" Class for managing BomItem data import/export """
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
sub_part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
class Meta:
model = BomItem
skip_unchanged = True
report_skipped = False
class BomItemAdmin(ImportExportModelAdmin):
resource_class = BomItemResource
list_display = ('part', 'sub_part', 'quantity')
search_fields = ('part__name', 'part__description', 'sub_part__name', 'sub_part__description')
class ParameterTemplateAdmin(ImportExportModelAdmin):
list_display = ('name', 'units')
class ParameterResource(ModelResource):
""" Class for managing PartParameter data import/export """
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
part_name = Field(attribute='part__name', readonly=True)
template = Field(attribute='template', widget=widgets.ForeignKeyWidget(PartParameterTemplate))
template_name = Field(attribute='template__name', readonly=True)
class Meta:
model = PartParameter
skip_unchanged = True
report_skipped = False
class ParameterAdmin(ImportExportModelAdmin):
resource_class = ParameterResource
list_display = ('part', 'template', 'data')

View File

@ -1,15 +1,93 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.contrib import admin
from import_export.admin import ImportExportModelAdmin
from import_export.resources import ModelResource
from import_export.fields import Field
import import_export.widgets as widgets
from .models import StockLocation, StockItem
from .models import StockItemTracking
from build.models import Build
from company.models import Company, SupplierPart
from order.models import PurchaseOrder
from part.models import Part
class LocationResource(ModelResource):
""" Class for managing StockLocation data import/export """
parent = Field(attribute='parent', widget=widgets.ForeignKeyWidget(StockLocation))
parent_name = Field(attribute='parent__name', readonly=True)
class Meta:
model = StockLocation
skip_unchanged = True
report_skipped = False
exclude = [
# Exclude MPTT internal model fields
'lft', 'rght', 'tree_id', 'level',
]
def after_import(self, dataset, result, using_transactions, dry_run, **kwargs):
super().after_import(dataset, result, using_transactions, dry_run, **kwargs)
# Rebuild the StockLocation tree(s)
StockLocation.objects.rebuild()
class LocationAdmin(ImportExportModelAdmin):
resource_class = LocationResource
list_display = ('name', 'pathstring', 'description')
search_fields = ('name', 'description')
class StockItemResource(ModelResource):
""" Class for managing StockItem data import/export """
# Custom manaegrs for ForeignKey fields
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
part_name = Field(attribute='part__name', readonly=True)
supplier_part = Field(attribute='supplier_part', widget=widgets.ForeignKeyWidget(SupplierPart))
location = Field(attribute='location', widget=widgets.ForeignKeyWidget(StockLocation))
location_name = Field(attribute='location__name', readonly=True)
belongs_to = Field(attribute='belongs_to', widget=widgets.ForeignKeyWidget(StockItem))
customer = Field(attribute='customer', widget=widgets.ForeignKeyWidget(Company))
build = Field(attribute='build', widget=widgets.ForeignKeyWidget(Build))
purchase_order = Field(attribute='purchase_order', widget=widgets.ForeignKeyWidget(PurchaseOrder))
# Date management
updated = Field(attribute='updated', widget=widgets.DateWidget())
stocktake_date = Field(attribute='stocktake_date', widget=widgets.DateWidget())
class Meta:
model = StockItem
skip_unchanged = True
report_skipped = False
class StockItemAdmin(ImportExportModelAdmin):
resource_class = StockItemResource
list_display = ('part', 'quantity', 'location', 'status', 'updated')