mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge pull request #810 from SchrodingersGat/serial-auto-fill
Serial auto fill
This commit is contained in:
commit
e550831efa
@ -7,13 +7,19 @@ from __future__ import unicode_literals
|
||||
|
||||
from django import forms
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout
|
||||
from crispy_forms.layout import Layout, Field
|
||||
from crispy_forms.bootstrap import PrependedText, AppendedText, PrependedAppendedText
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
|
||||
class HelperForm(forms.ModelForm):
|
||||
""" Provides simple integration of crispy_forms extension. """
|
||||
|
||||
# Custom field decorations can be specified here, per form class
|
||||
field_prefix = {}
|
||||
field_suffix = {}
|
||||
field_placeholder = {}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(forms.ModelForm, self).__init__(*args, **kwargs)
|
||||
self.helper = FormHelper()
|
||||
@ -28,7 +34,62 @@ class HelperForm(forms.ModelForm):
|
||||
Simply create a 'blank' layout for each available field.
|
||||
"""
|
||||
|
||||
self.helper.layout = Layout(*self.fields.keys())
|
||||
self.rebuild_layout()
|
||||
|
||||
def rebuild_layout(self):
|
||||
|
||||
layouts = []
|
||||
|
||||
for field in self.fields:
|
||||
prefix = self.field_prefix.get(field, None)
|
||||
suffix = self.field_suffix.get(field, None)
|
||||
placeholder = self.field_placeholder.get(field, '')
|
||||
|
||||
# Look for font-awesome icons
|
||||
if prefix and prefix.startswith('fa-'):
|
||||
prefix = r"<i class='fas {fa}'/>".format(fa=prefix)
|
||||
|
||||
if suffix and suffix.startswith('fa-'):
|
||||
suffix = r"<i class='fas {fa}'/>".format(fa=suffix)
|
||||
|
||||
if prefix and suffix:
|
||||
layouts.append(
|
||||
Field(
|
||||
PrependedAppendedText(
|
||||
field,
|
||||
prepended_text=prefix,
|
||||
appended_text=suffix,
|
||||
placeholder=placeholder
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
elif prefix:
|
||||
layouts.append(
|
||||
Field(
|
||||
PrependedText(
|
||||
field,
|
||||
prefix,
|
||||
placeholder=placeholder
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
elif suffix:
|
||||
layouts.append(
|
||||
Field(
|
||||
AppendedText(
|
||||
field,
|
||||
suffix,
|
||||
placeholder=placeholder
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
else:
|
||||
layouts.append(Field(field, placeholder=placeholder))
|
||||
|
||||
self.helper.layout = Layout(*layouts)
|
||||
|
||||
|
||||
class DeleteForm(forms.Form):
|
||||
|
@ -46,12 +46,23 @@ class ConfirmBuildForm(HelperForm):
|
||||
class CompleteBuildForm(HelperForm):
|
||||
""" Form for marking a Build as complete """
|
||||
|
||||
field_prefix = {
|
||||
'serial_numbers': 'fa-hashtag',
|
||||
}
|
||||
|
||||
field_placeholder = {
|
||||
}
|
||||
|
||||
location = forms.ModelChoiceField(
|
||||
queryset=StockLocation.objects.all(),
|
||||
help_text='Location of completed parts',
|
||||
help_text=_('Location of completed parts'),
|
||||
)
|
||||
|
||||
serial_numbers = forms.CharField(label='Serial numbers', required=False, help_text=_('Enter unique serial numbers (or leave blank)'))
|
||||
serial_numbers = forms.CharField(
|
||||
label=_('Serial numbers'),
|
||||
required=False,
|
||||
help_text=_('Enter unique serial numbers (or leave blank)')
|
||||
)
|
||||
|
||||
confirm = forms.BooleanField(required=False, help_text=_('Confirm build completion'))
|
||||
|
||||
|
@ -53,6 +53,22 @@ class Build(MPTTModel):
|
||||
def get_absolute_url(self):
|
||||
return reverse('build-detail', kwargs={'pk': self.id})
|
||||
|
||||
def clean(self):
|
||||
"""
|
||||
Validation for Build object.
|
||||
"""
|
||||
|
||||
super().clean()
|
||||
|
||||
try:
|
||||
if self.part.trackable:
|
||||
if not self.quantity == int(self.quantity):
|
||||
raise ValidationError({
|
||||
'quantity': _("Build quantity must be integer value for trackable parts")
|
||||
})
|
||||
except PartModels.Part.DoesNotExist:
|
||||
pass
|
||||
|
||||
title = models.CharField(
|
||||
verbose_name=_('Build Title'),
|
||||
blank=False,
|
||||
|
@ -196,6 +196,15 @@ class BuildComplete(AjaxUpdateView):
|
||||
|
||||
if not build.part.trackable:
|
||||
form.fields.pop('serial_numbers')
|
||||
else:
|
||||
if build.quantity == 1:
|
||||
text = _('Next available serial number is')
|
||||
else:
|
||||
text = _('Next available serial numbers are')
|
||||
|
||||
form.field_placeholder['serial_numbers'] = text + " " + build.part.getSerialNumberString(build.quantity)
|
||||
|
||||
form.rebuild_layout()
|
||||
|
||||
return form
|
||||
|
||||
@ -208,6 +217,7 @@ class BuildComplete(AjaxUpdateView):
|
||||
initials = super(BuildComplete, self).get_initial().copy()
|
||||
|
||||
build = self.get_object()
|
||||
|
||||
if build.part.default_location is not None:
|
||||
try:
|
||||
location = StockLocation.objects.get(pk=build.part.default_location.id)
|
||||
@ -282,7 +292,7 @@ class BuildComplete(AjaxUpdateView):
|
||||
existing = []
|
||||
|
||||
for serial in serials:
|
||||
if not StockItem.check_serial_number(build.part, serial):
|
||||
if build.part.checkIfSerialNumberExists(serial):
|
||||
existing.append(serial)
|
||||
|
||||
if len(existing) > 0:
|
||||
|
@ -16,6 +16,14 @@ from .models import SupplierPriceBreak
|
||||
class EditCompanyForm(HelperForm):
|
||||
""" Form for editing a Company object """
|
||||
|
||||
field_prefix = {
|
||||
'website': 'fa-globe-asia',
|
||||
'email': 'fa-at',
|
||||
'address': 'fa-envelope',
|
||||
'contact': 'fa-user-tie',
|
||||
'phone': 'fa-phone',
|
||||
}
|
||||
|
||||
class Meta:
|
||||
model = Company
|
||||
fields = [
|
||||
@ -45,6 +53,13 @@ class CompanyImageForm(HelperForm):
|
||||
class EditSupplierPartForm(HelperForm):
|
||||
""" Form for editing a SupplierPart object """
|
||||
|
||||
field_prefix = {
|
||||
'link': 'fa-link',
|
||||
'MPN': 'fa-hashtag',
|
||||
'SKU': 'fa-hashtag',
|
||||
'note': 'fa-pencil-alt',
|
||||
}
|
||||
|
||||
class Meta:
|
||||
model = SupplierPart
|
||||
fields = [
|
||||
|
@ -8,9 +8,6 @@ from __future__ import unicode_literals
|
||||
from django import forms
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from crispy_forms.layout import Field, Layout
|
||||
from crispy_forms.bootstrap import PrependedText
|
||||
|
||||
from mptt.fields import TreeNodeChoiceField
|
||||
|
||||
from InvenTree.forms import HelperForm
|
||||
@ -93,20 +90,16 @@ class EditPurchaseOrderForm(HelperForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
self.field_prefix = {
|
||||
'reference': 'PO',
|
||||
'link': 'fa-link',
|
||||
}
|
||||
|
||||
# TODO - Refactor this?
|
||||
self.helper.layout = Layout(
|
||||
Field(PrependedText(
|
||||
'reference',
|
||||
'PO',
|
||||
placeholder=_("Purchase Order")
|
||||
)),
|
||||
Field('supplier'),
|
||||
Field('supplier_reference'),
|
||||
Field('description'),
|
||||
Field('link'),
|
||||
)
|
||||
self.field_placeholder = {
|
||||
'reference': _('Enter purchase order number'),
|
||||
}
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
model = PurchaseOrder
|
||||
@ -124,20 +117,16 @@ class EditSalesOrderForm(HelperForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
self.field_prefix = {
|
||||
'reference': 'SO',
|
||||
'link': 'fa-link',
|
||||
}
|
||||
|
||||
# TODO - Refactor?
|
||||
self.helper.layout = Layout(
|
||||
Field(PrependedText(
|
||||
'reference',
|
||||
'SO',
|
||||
placeholder=_("Sales Order")
|
||||
)),
|
||||
Field('customer'),
|
||||
Field('customer_reference'),
|
||||
Field('description'),
|
||||
Field('link'),
|
||||
)
|
||||
self.field_placeholder = {
|
||||
'reference': _('Enter sales order number'),
|
||||
}
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
model = SalesOrder
|
||||
|
@ -7,6 +7,10 @@
|
||||
description: 'M2x4 low profile head screw'
|
||||
category: 8
|
||||
link: www.acme.com/parts/m2x4lphs
|
||||
tree_id: 0
|
||||
level: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: part.part
|
||||
pk: 2
|
||||
@ -14,6 +18,10 @@
|
||||
name: 'M3x12 SHCS'
|
||||
description: 'M3x12 socket head cap screw'
|
||||
category: 8
|
||||
tree_id: 0
|
||||
level: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
# Create some resistors
|
||||
|
||||
@ -23,6 +31,11 @@
|
||||
name: 'R_2K2_0805'
|
||||
description: '2.2kOhm resistor in 0805 package'
|
||||
category: 2
|
||||
tree_id: 0
|
||||
level: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
|
||||
- model: part.part
|
||||
fields:
|
||||
@ -30,6 +43,10 @@
|
||||
description: '4.7kOhm resistor in 0603 package'
|
||||
category: 2
|
||||
default_location: 2 # Home/Bathroom
|
||||
tree_id: 0
|
||||
level: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
# Create some capacitors
|
||||
- model: part.part
|
||||
@ -37,6 +54,10 @@
|
||||
name: 'C_22N_0805'
|
||||
description: '22nF capacitor in 0805 package'
|
||||
category: 3
|
||||
tree_id: 0
|
||||
level: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: part.part
|
||||
pk: 25
|
||||
@ -45,6 +66,10 @@
|
||||
description: 'A watchamacallit'
|
||||
category: 7
|
||||
trackable: true
|
||||
tree_id: 0
|
||||
level: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: part.part
|
||||
pk: 50
|
||||
@ -52,6 +77,10 @@
|
||||
name: 'Orphan'
|
||||
description: 'A part without a category'
|
||||
category: null
|
||||
tree_id: 0
|
||||
level: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
# A part that can be made from other parts
|
||||
- model: part.part
|
||||
@ -64,4 +93,65 @@
|
||||
category: 7
|
||||
active: False
|
||||
IPN: BOB
|
||||
revision: A2
|
||||
revision: A2
|
||||
tree_id: 0
|
||||
level: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
# A 'template' part
|
||||
- model: part.part
|
||||
pk: 10000
|
||||
fields:
|
||||
name: 'Chair Template'
|
||||
description: 'A chair'
|
||||
is_template: True
|
||||
category: 7
|
||||
tree_id: 1
|
||||
level: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: part.part
|
||||
pk: 10001
|
||||
fields:
|
||||
name: 'Blue Chair'
|
||||
variant_of: 10000
|
||||
category: 7
|
||||
tree_id: 1
|
||||
level: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: part.part
|
||||
pk: 10002
|
||||
fields:
|
||||
name: 'Red chair'
|
||||
variant_of: 10000
|
||||
category: 7
|
||||
tree_id: 1
|
||||
level: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: part.part
|
||||
pk: 10003
|
||||
fields:
|
||||
name: 'Green chair'
|
||||
variant_of: 10000
|
||||
category: 7
|
||||
tree_id: 1
|
||||
level: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: part.part
|
||||
pk: 10004
|
||||
fields:
|
||||
name: 'Green chair variant'
|
||||
variant_of: 10003
|
||||
category:
|
||||
tree_id: 1
|
||||
level: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
@ -97,6 +97,12 @@ class SetPartCategoryForm(forms.Form):
|
||||
class EditPartForm(HelperForm):
|
||||
""" Form for editing a Part object """
|
||||
|
||||
field_prefix = {
|
||||
'keywords': 'fa-key',
|
||||
'link': 'fa-link',
|
||||
'IPN': 'fa-hashtag',
|
||||
}
|
||||
|
||||
deep_copy = forms.BooleanField(required=False,
|
||||
initial=True,
|
||||
help_text=_("Perform 'deep copy' which will duplicate all BOM data for this part"),
|
||||
@ -155,6 +161,10 @@ class EditPartParameterForm(HelperForm):
|
||||
class EditCategoryForm(HelperForm):
|
||||
""" Form for editing a PartCategory object """
|
||||
|
||||
field_prefix = {
|
||||
'default_keywords': 'fa-key',
|
||||
}
|
||||
|
||||
class Meta:
|
||||
model = PartCategory
|
||||
fields = [
|
||||
|
50
InvenTree/part/migrations/0039_auto_20200515_1127.py
Normal file
50
InvenTree/part/migrations/0039_auto_20200515_1127.py
Normal file
@ -0,0 +1,50 @@
|
||||
# Generated by Django 3.0.5 on 2020-05-15 11:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
from part.models import Part
|
||||
|
||||
|
||||
def update_tree(apps, schema_editor):
|
||||
# Update the MPTT for Part model
|
||||
Part.objects.rebuild()
|
||||
|
||||
|
||||
def nupdate_tree(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('part', '0038_auto_20200513_0016'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='part',
|
||||
name='level',
|
||||
field=models.PositiveIntegerField(default=0, editable=False),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='part',
|
||||
name='lft',
|
||||
field=models.PositiveIntegerField(default=0, editable=False),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='part',
|
||||
name='rght',
|
||||
field=models.PositiveIntegerField(default=0, editable=False),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='part',
|
||||
name='tree_id',
|
||||
field=models.PositiveIntegerField(db_index=True, default=0, editable=False),
|
||||
preserve_default=False,
|
||||
),
|
||||
|
||||
migrations.RunPython(update_tree, reverse_code=nupdate_tree)
|
||||
]
|
@ -24,7 +24,7 @@ from markdownx.models import MarkdownxField
|
||||
|
||||
from django_cleanup import cleanup
|
||||
|
||||
from mptt.models import TreeForeignKey
|
||||
from mptt.models import TreeForeignKey, MPTTModel
|
||||
|
||||
from stdimage.models import StdImageField
|
||||
|
||||
@ -39,7 +39,7 @@ from InvenTree.models import InvenTreeTree, InvenTreeAttachment
|
||||
from InvenTree.fields import InvenTreeURLField
|
||||
from InvenTree.helpers import decimal2string, normalize
|
||||
|
||||
from InvenTree.status_codes import BuildStatus, StockStatus, PurchaseOrderStatus
|
||||
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
|
||||
|
||||
from build import models as BuildModels
|
||||
from order import models as OrderModels
|
||||
@ -200,7 +200,7 @@ def match_part_names(match, threshold=80, reverse=True, compare_length=False):
|
||||
|
||||
|
||||
@cleanup.ignore
|
||||
class Part(models.Model):
|
||||
class Part(MPTTModel):
|
||||
""" The Part object represents an abstract part, the 'concept' of an actual entity.
|
||||
|
||||
An actual physical instance of a Part is a StockItem which is treated separately.
|
||||
@ -236,8 +236,12 @@ class Part(models.Model):
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Part"
|
||||
verbose_name_plural = "Parts"
|
||||
verbose_name = _("Part")
|
||||
verbose_name_plural = _("Parts")
|
||||
|
||||
class MPTTMeta:
|
||||
# For legacy reasons the 'variant_of' field is used to indicate the MPTT parent
|
||||
parent_attr = 'variant_of'
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
@ -262,6 +266,66 @@ class Part(models.Model):
|
||||
def __str__(self):
|
||||
return "{n} - {d}".format(n=self.full_name, d=self.description)
|
||||
|
||||
def checkIfSerialNumberExists(self, sn):
|
||||
"""
|
||||
Check if a serial number exists for this Part.
|
||||
|
||||
Note: Serial numbers must be unique across an entire Part "tree",
|
||||
so here we filter by the entire tree.
|
||||
"""
|
||||
|
||||
parts = Part.objects.filter(tree_id=self.tree_id)
|
||||
stock = StockModels.StockItem.objects.filter(part__in=parts, serial=sn)
|
||||
|
||||
return stock.exists()
|
||||
|
||||
def getHighestSerialNumber(self):
|
||||
"""
|
||||
Return the highest serial number for this Part.
|
||||
|
||||
Note: Serial numbers must be unique across an entire Part "tree",
|
||||
so we filter by the entire tree.
|
||||
"""
|
||||
|
||||
parts = Part.objects.filter(tree_id=self.tree_id)
|
||||
stock = StockModels.StockItem.objects.filter(part__in=parts).exclude(serial=None).order_by('-serial')
|
||||
|
||||
if stock.count() > 0:
|
||||
return stock.first().serial
|
||||
|
||||
# No serial numbers found
|
||||
return None
|
||||
|
||||
def getNextSerialNumber(self):
|
||||
"""
|
||||
Return the next-available serial number for this Part.
|
||||
"""
|
||||
|
||||
n = self.getHighestSerialNumber()
|
||||
|
||||
if n is None:
|
||||
return 1
|
||||
else:
|
||||
return n + 1
|
||||
|
||||
def getSerialNumberString(self, quantity):
|
||||
"""
|
||||
Return a formatted string representing the next available serial numbers,
|
||||
given a certain quantity of items.
|
||||
"""
|
||||
|
||||
sn = self.getNextSerialNumber()
|
||||
|
||||
if quantity >= 2:
|
||||
sn = "{n}-{m}".format(
|
||||
n=sn,
|
||||
m=int(sn + quantity - 1)
|
||||
)
|
||||
else:
|
||||
sn = str(sn)
|
||||
|
||||
return sn
|
||||
|
||||
@property
|
||||
def full_name(self):
|
||||
""" Format a 'full name' for this Part.
|
||||
@ -638,32 +702,40 @@ class Part(models.Model):
|
||||
self.sales_order_allocation_count(),
|
||||
])
|
||||
|
||||
@property
|
||||
def stock_entries(self):
|
||||
""" Return all 'in stock' items. To be in stock:
|
||||
def stock_entries(self, include_variants=True, in_stock=None):
|
||||
""" Return all stock entries for this Part.
|
||||
|
||||
- build_order is None
|
||||
- sales_order is None
|
||||
- belongs_to is None
|
||||
- If this is a template part, include variants underneath this.
|
||||
|
||||
Note: To return all stock-entries for all part variants under this one,
|
||||
we need to be creative with the filtering.
|
||||
"""
|
||||
|
||||
return self.stock_items.filter(StockModels.StockItem.IN_STOCK_FILTER)
|
||||
if include_variants:
|
||||
query = StockModels.StockItem.objects.filter(part__in=self.get_descendants(include_self=True))
|
||||
else:
|
||||
query = self.stock_items
|
||||
|
||||
if in_stock is True:
|
||||
query = query.filter(StockModels.StockItem.IN_STOCK_FILTER)
|
||||
elif in_stock is False:
|
||||
query = query.exclude(StockModels.StockItem.IN_STOCK_FILTER)
|
||||
|
||||
return query
|
||||
|
||||
@property
|
||||
def total_stock(self):
|
||||
""" Return the total stock quantity for this part.
|
||||
Part may be stored in multiple locations
|
||||
|
||||
- Part may be stored in multiple locations
|
||||
- If this part is a "template" (variants exist) then these are counted too
|
||||
"""
|
||||
|
||||
if self.is_template:
|
||||
total = sum([variant.total_stock for variant in self.variants.all()])
|
||||
else:
|
||||
total = self.stock_entries.filter(status__in=StockStatus.AVAILABLE_CODES).aggregate(total=Sum('quantity'))['total']
|
||||
entries = self.stock_entries(in_stock=True)
|
||||
|
||||
if total:
|
||||
return total
|
||||
else:
|
||||
return Decimal(0)
|
||||
query = entries.aggregate(t=Coalesce(Sum('quantity'), Decimal(0)))
|
||||
|
||||
return query['t']
|
||||
|
||||
@property
|
||||
def has_bom(self):
|
||||
|
@ -85,7 +85,7 @@ class PartAPITest(APITestCase):
|
||||
data = {'cascade': True}
|
||||
response = self.client.get(url, data, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 8)
|
||||
self.assertEqual(len(response.data), 13)
|
||||
|
||||
def test_get_parts_by_cat(self):
|
||||
url = reverse('api-part-list')
|
||||
|
@ -88,9 +88,9 @@ class CategoryTest(TestCase):
|
||||
|
||||
self.assertEqual(self.electronics.partcount(), 3)
|
||||
|
||||
self.assertEqual(self.mechanical.partcount(), 4)
|
||||
self.assertEqual(self.mechanical.partcount(active=True), 3)
|
||||
self.assertEqual(self.mechanical.partcount(False), 2)
|
||||
self.assertEqual(self.mechanical.partcount(), 8)
|
||||
self.assertEqual(self.mechanical.partcount(active=True), 7)
|
||||
self.assertEqual(self.mechanical.partcount(False), 6)
|
||||
|
||||
self.assertEqual(self.electronics.item_count, self.electronics.partcount())
|
||||
|
||||
|
@ -52,6 +52,20 @@ class PartTest(TestCase):
|
||||
|
||||
self.C1 = Part.objects.get(name='C_22N_0805')
|
||||
|
||||
Part.objects.rebuild()
|
||||
|
||||
def test_tree(self):
|
||||
# Test that the part variant tree is working properly
|
||||
chair = Part.objects.get(pk=10000)
|
||||
self.assertEqual(chair.get_children().count(), 3)
|
||||
self.assertEqual(chair.get_descendant_count(), 4)
|
||||
|
||||
green = Part.objects.get(pk=10004)
|
||||
self.assertEqual(green.get_ancestors().count(), 2)
|
||||
self.assertEqual(green.get_root(), chair)
|
||||
self.assertEqual(green.get_family().count(), 3)
|
||||
self.assertEqual(Part.objects.filter(tree_id=chair.tree_id).count(), 5)
|
||||
|
||||
def test_str(self):
|
||||
p = Part.objects.get(pk=100)
|
||||
self.assertEqual(str(p), "BOB | Bob | A2 - Can we build it?")
|
||||
|
@ -68,4 +68,160 @@
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
# Stock items for template / variant parts
|
||||
- model: stock.stockitem
|
||||
pk: 500
|
||||
fields:
|
||||
part: 10001
|
||||
location: 7
|
||||
quantity: 5
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 501
|
||||
fields:
|
||||
part: 10001
|
||||
location: 7
|
||||
quantity: 1
|
||||
serial: 1
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 501
|
||||
fields:
|
||||
part: 10001
|
||||
location: 7
|
||||
quantity: 1
|
||||
serial: 1
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 502
|
||||
fields:
|
||||
part: 10001
|
||||
location: 7
|
||||
quantity: 1
|
||||
serial: 2
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 503
|
||||
fields:
|
||||
part: 10001
|
||||
location: 7
|
||||
quantity: 1
|
||||
serial: 3
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 504
|
||||
fields:
|
||||
part: 10001
|
||||
location: 7
|
||||
quantity: 1
|
||||
serial: 4
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 505
|
||||
fields:
|
||||
part: 10001
|
||||
location: 7
|
||||
quantity: 1
|
||||
serial: 5
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 510
|
||||
fields:
|
||||
part: 10002
|
||||
location: 7
|
||||
quantity: 1
|
||||
serial: 10
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 511
|
||||
fields:
|
||||
part: 10002
|
||||
location: 7
|
||||
quantity: 1
|
||||
serial: 11
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 512
|
||||
fields:
|
||||
part: 10002
|
||||
location: 7
|
||||
quantity: 1
|
||||
serial: 12
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 520
|
||||
fields:
|
||||
part: 10004
|
||||
location: 7
|
||||
quantity: 1
|
||||
serial: 20
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 521
|
||||
fields:
|
||||
part: 10004
|
||||
location: 7
|
||||
quantity: 1
|
||||
serial: 21
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
||||
|
||||
- model: stock.stockitem
|
||||
pk: 522
|
||||
fields:
|
||||
part: 10004
|
||||
location: 7
|
||||
quantity: 1
|
||||
serial: 22
|
||||
level: 0
|
||||
tree_id: 0
|
||||
lft: 0
|
||||
rght: 0
|
@ -13,6 +13,8 @@ from mptt.fields import TreeNodeChoiceField
|
||||
|
||||
from InvenTree.helpers import GetExportFormats
|
||||
from InvenTree.forms import HelperForm
|
||||
from InvenTree.fields import RoundingDecimalFormField
|
||||
|
||||
from .models import StockLocation, StockItem, StockItemTracking, StockItemAttachment
|
||||
|
||||
|
||||
@ -47,6 +49,15 @@ class CreateStockItemForm(HelperForm):
|
||||
|
||||
serial_numbers = forms.CharField(label='Serial numbers', required=False, help_text=_('Enter unique serial numbers (or leave blank)'))
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
self.field_prefix = {
|
||||
'serial_numbers': 'fa-hashtag',
|
||||
'link': 'fa-link',
|
||||
}
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
model = StockItem
|
||||
fields = [
|
||||
@ -69,6 +80,7 @@ class CreateStockItemForm(HelperForm):
|
||||
return
|
||||
|
||||
self.cleaned_data = {}
|
||||
|
||||
# If the form is permitted to be empty, and none of the form data has
|
||||
# changed from the initial data, short circuit any validation.
|
||||
if self.empty_permitted and not self.has_changed():
|
||||
@ -79,7 +91,7 @@ class CreateStockItemForm(HelperForm):
|
||||
self._clean_form()
|
||||
|
||||
|
||||
class SerializeStockForm(forms.ModelForm):
|
||||
class SerializeStockForm(HelperForm):
|
||||
""" Form for serializing a StockItem. """
|
||||
|
||||
destination = TreeNodeChoiceField(queryset=StockLocation.objects.all(), label='Destination', required=True, help_text='Destination for serialized stock (by default, will remain in current location)')
|
||||
@ -88,6 +100,18 @@ class SerializeStockForm(forms.ModelForm):
|
||||
|
||||
note = forms.CharField(label='Notes', required=False, help_text='Add transaction note (optional)')
|
||||
|
||||
quantity = RoundingDecimalFormField(max_digits=10, decimal_places=5)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
# Extract the stock item
|
||||
item = kwargs.pop('item', None)
|
||||
|
||||
if item:
|
||||
self.field_placeholder['serial_numbers'] = item.part.getSerialNumberString(item.quantity)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
model = StockItem
|
||||
|
||||
|
@ -142,9 +142,21 @@ class StockItem(MPTTModel):
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Save this StockItem to the database. Performs a number of checks:
|
||||
|
||||
- Unique serial number requirement
|
||||
- Adds a transaction note when the item is first created.
|
||||
"""
|
||||
|
||||
self.validate_unique()
|
||||
self.clean()
|
||||
|
||||
if not self.pk:
|
||||
# StockItem has not yet been saved
|
||||
add_note = True
|
||||
else:
|
||||
# StockItem has already been saved
|
||||
add_note = False
|
||||
|
||||
user = kwargs.pop('user', None)
|
||||
@ -172,59 +184,26 @@ class StockItem(MPTTModel):
|
||||
""" Return True if this StockItem is serialized """
|
||||
return self.serial is not None and self.quantity == 1
|
||||
|
||||
@classmethod
|
||||
def check_serial_number(cls, part, serial_number):
|
||||
""" Check if a new stock item can be created with the provided part_id
|
||||
|
||||
Args:
|
||||
part: The part to be checked
|
||||
def validate_unique(self, exclude=None):
|
||||
"""
|
||||
Test that this StockItem is "unique".
|
||||
If the StockItem is serialized, the same serial number.
|
||||
cannot exist for the same part (or part tree).
|
||||
"""
|
||||
|
||||
if not part.trackable:
|
||||
return False
|
||||
|
||||
# Return False if an invalid serial number is supplied
|
||||
try:
|
||||
serial_number = int(serial_number)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
items = StockItem.objects.filter(serial=serial_number)
|
||||
|
||||
# Is this part a variant? If so, check S/N across all sibling variants
|
||||
if part.variant_of is not None:
|
||||
items = items.filter(part__variant_of=part.variant_of)
|
||||
else:
|
||||
items = items.filter(part=part)
|
||||
|
||||
# An existing serial number exists
|
||||
if items.exists():
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def validate_unique(self, exclude=None):
|
||||
super(StockItem, self).validate_unique(exclude)
|
||||
|
||||
# If the Part object is a variant (of a template part),
|
||||
# ensure that the serial number is unique
|
||||
# across all variants of the same template part
|
||||
if self.serial is not None:
|
||||
# Query to look for duplicate serial numbers
|
||||
parts = PartModels.Part.objects.filter(tree_id=self.part.tree_id)
|
||||
stock = StockItem.objects.filter(part__in=parts, serial=self.serial)
|
||||
|
||||
try:
|
||||
if self.serial is not None:
|
||||
# This is a variant part (check S/N across all sibling variants)
|
||||
if self.part.variant_of is not None:
|
||||
if StockItem.objects.filter(part__variant_of=self.part.variant_of, serial=self.serial).exclude(id=self.id).exists():
|
||||
raise ValidationError({
|
||||
'serial': _('A stock item with this serial number already exists for template part {part}'.format(part=self.part.variant_of))
|
||||
})
|
||||
else:
|
||||
if StockItem.objects.filter(part=self.part, serial=self.serial).exclude(id=self.id).exists():
|
||||
raise ValidationError({
|
||||
'serial': _('A stock item with this serial number already exists')
|
||||
})
|
||||
except PartModels.Part.DoesNotExist:
|
||||
pass
|
||||
# Exclude myself from the search
|
||||
if self.pk is not None:
|
||||
stock = stock.exclude(pk=self.pk)
|
||||
|
||||
if stock.exists():
|
||||
raise ValidationError({"serial": _("StockItem with this serial number already exists")})
|
||||
|
||||
def clean(self):
|
||||
""" Validate the StockItem object (separate to field validation)
|
||||
@ -236,6 +215,8 @@ class StockItem(MPTTModel):
|
||||
- Quantity must be 1 if the StockItem has a serial number
|
||||
"""
|
||||
|
||||
super().clean()
|
||||
|
||||
if self.status == StockStatus.SHIPPED and self.sales_order is None:
|
||||
raise ValidationError({
|
||||
'sales_order': "SalesOrder must be specified as status is marked as SHIPPED",
|
||||
@ -248,6 +229,24 @@ class StockItem(MPTTModel):
|
||||
'status': 'Status cannot be marked as ASSIGNED_TO_OTHER_ITEM if the belongs_to field is not set',
|
||||
})
|
||||
|
||||
try:
|
||||
if self.part.trackable:
|
||||
# Trackable parts must have integer values for quantity field!
|
||||
if not self.quantity == int(self.quantity):
|
||||
raise ValidationError({
|
||||
'quantity': _('Quantity must be integer value for trackable parts')
|
||||
})
|
||||
except PartModels.Part.DoesNotExist:
|
||||
# For some reason the 'clean' process sometimes throws errors because self.part does not exist
|
||||
# It *seems* that this only occurs in unit testing, though.
|
||||
# Probably should investigate this at some point.
|
||||
pass
|
||||
|
||||
if self.quantity < 0:
|
||||
raise ValidationError({
|
||||
'quantity': _('Quantity must be greater than zero')
|
||||
})
|
||||
|
||||
# The 'supplier_part' field must point to the same part!
|
||||
try:
|
||||
if self.supplier_part is not None:
|
||||
@ -599,6 +598,9 @@ class StockItem(MPTTModel):
|
||||
if self.serialized:
|
||||
return
|
||||
|
||||
if not self.part.trackable:
|
||||
raise ValidationError({"part": _("Part is not set as trackable")})
|
||||
|
||||
# Quantity must be a valid integer value
|
||||
try:
|
||||
quantity = int(quantity)
|
||||
@ -624,7 +626,7 @@ class StockItem(MPTTModel):
|
||||
existing = []
|
||||
|
||||
for serial in serials:
|
||||
if not StockItem.check_serial_number(self.part, serial):
|
||||
if self.part.checkIfSerialNumberExists(serial):
|
||||
existing.append(serial)
|
||||
|
||||
if len(existing) > 0:
|
||||
|
@ -38,6 +38,10 @@ class StockTest(TestCase):
|
||||
|
||||
self.user = User.objects.get(username='username')
|
||||
|
||||
# Ensure the MPTT objects are correctly rebuild
|
||||
Part.objects.rebuild()
|
||||
StockItem.objects.rebuild()
|
||||
|
||||
def test_loc_count(self):
|
||||
self.assertEqual(StockLocation.objects.count(), 7)
|
||||
|
||||
@ -91,13 +95,16 @@ class StockTest(TestCase):
|
||||
self.assertFalse(self.drawer2.has_items())
|
||||
|
||||
# Drawer 3 should have three stock items
|
||||
self.assertEqual(self.drawer3.stock_items.count(), 3)
|
||||
self.assertEqual(self.drawer3.item_count, 3)
|
||||
self.assertEqual(self.drawer3.stock_items.count(), 15)
|
||||
self.assertEqual(self.drawer3.item_count, 15)
|
||||
|
||||
def test_stock_count(self):
|
||||
part = Part.objects.get(pk=1)
|
||||
entries = part.stock_entries()
|
||||
|
||||
# There should be 5000 screws in stock
|
||||
self.assertEqual(entries.count(), 2)
|
||||
|
||||
# There should be 9000 screws in stock
|
||||
self.assertEqual(part.total_stock, 9000)
|
||||
|
||||
# There should be 18 widgets in stock
|
||||
@ -327,3 +334,73 @@ class StockTest(TestCase):
|
||||
|
||||
# Serialize the remainder of the stock
|
||||
item.serializeStock(2, [99, 100], self.user)
|
||||
|
||||
|
||||
class VariantTest(StockTest):
|
||||
"""
|
||||
Tests for calculation stock counts against templates / variants
|
||||
"""
|
||||
|
||||
def test_variant_stock(self):
|
||||
# Check the 'Chair' variant
|
||||
chair = Part.objects.get(pk=10000)
|
||||
|
||||
# No stock items for the variant part itself
|
||||
self.assertEqual(chair.stock_entries(include_variants=False).count(), 0)
|
||||
|
||||
self.assertEqual(chair.stock_entries().count(), 12)
|
||||
|
||||
green = Part.objects.get(pk=10003)
|
||||
self.assertEqual(green.stock_entries(include_variants=False).count(), 0)
|
||||
self.assertEqual(green.stock_entries().count(), 3)
|
||||
|
||||
def test_serial_numbers(self):
|
||||
# Test serial number functionality for variant / template parts
|
||||
|
||||
chair = Part.objects.get(pk=10000)
|
||||
|
||||
# Operations on the top-level object
|
||||
self.assertTrue(chair.checkIfSerialNumberExists(1))
|
||||
self.assertTrue(chair.checkIfSerialNumberExists(2))
|
||||
self.assertTrue(chair.checkIfSerialNumberExists(3))
|
||||
self.assertTrue(chair.checkIfSerialNumberExists(4))
|
||||
self.assertTrue(chair.checkIfSerialNumberExists(5))
|
||||
|
||||
self.assertTrue(chair.checkIfSerialNumberExists(20))
|
||||
self.assertTrue(chair.checkIfSerialNumberExists(21))
|
||||
self.assertTrue(chair.checkIfSerialNumberExists(22))
|
||||
|
||||
self.assertFalse(chair.checkIfSerialNumberExists(30))
|
||||
|
||||
self.assertEqual(chair.getNextSerialNumber(), 23)
|
||||
|
||||
# Same operations on a sub-item
|
||||
variant = Part.objects.get(pk=10003)
|
||||
self.assertEqual(variant.getNextSerialNumber(), 23)
|
||||
|
||||
# Create a new serial number
|
||||
n = variant.getHighestSerialNumber()
|
||||
|
||||
item = StockItem(
|
||||
part=variant,
|
||||
quantity=1,
|
||||
serial=n
|
||||
)
|
||||
|
||||
# This should fail
|
||||
with self.assertRaises(ValidationError):
|
||||
item.save()
|
||||
|
||||
# This should pass
|
||||
item.serial = n + 1
|
||||
item.save()
|
||||
|
||||
# Attempt to create the same serial number but for a variant (should fail!)
|
||||
item.pk = None
|
||||
item.part = Part.objects.get(pk=10004)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
item.save()
|
||||
|
||||
item.serial += 1
|
||||
item.save()
|
||||
|
@ -717,7 +717,7 @@ class StockItemEdit(AjaxUpdateView):
|
||||
query = query.filter(part=item.part.id)
|
||||
form.fields['supplier_part'].queryset = query
|
||||
|
||||
if not item.part.trackable:
|
||||
if not item.part.trackable or not item.serialized:
|
||||
form.fields.pop('serial')
|
||||
|
||||
return form
|
||||
@ -757,6 +757,17 @@ class StockItemSerialize(AjaxUpdateView):
|
||||
ajax_form_title = _('Serialize Stock')
|
||||
form_class = SerializeStockForm
|
||||
|
||||
def get_form(self):
|
||||
|
||||
context = self.get_form_kwargs()
|
||||
|
||||
# Pass the StockItem object through to the form
|
||||
context['item'] = self.get_object()
|
||||
|
||||
form = SerializeStockForm(**context)
|
||||
|
||||
return form
|
||||
|
||||
def get_initial(self):
|
||||
|
||||
initials = super().get_initial().copy()
|
||||
@ -764,6 +775,7 @@ class StockItemSerialize(AjaxUpdateView):
|
||||
item = self.get_object()
|
||||
|
||||
initials['quantity'] = item.quantity
|
||||
initials['serial_numbers'] = item.part.getSerialNumberString(item.quantity)
|
||||
initials['destination'] = item.location.pk
|
||||
|
||||
return initials
|
||||
@ -844,6 +856,8 @@ class StockItemCreate(AjaxCreateView):
|
||||
|
||||
form = super().get_form()
|
||||
|
||||
part = None
|
||||
|
||||
# If the user has selected a Part, limit choices for SupplierPart
|
||||
if form['part'].value():
|
||||
part_id = form['part'].value()
|
||||
@ -851,6 +865,11 @@ class StockItemCreate(AjaxCreateView):
|
||||
try:
|
||||
part = Part.objects.get(id=part_id)
|
||||
|
||||
sn = part.getNextSerialNumber()
|
||||
form.field_placeholder['serial_numbers'] = _('Next available serial number is') + ' ' + str(sn)
|
||||
|
||||
form.rebuild_layout()
|
||||
|
||||
# Hide the 'part' field (as a valid part is selected)
|
||||
form.fields['part'].widget = HiddenInput()
|
||||
|
||||
@ -873,6 +892,7 @@ class StockItemCreate(AjaxCreateView):
|
||||
|
||||
# If there is one (and only one) supplier part available, pre-select it
|
||||
all_parts = parts.all()
|
||||
|
||||
if len(all_parts) == 1:
|
||||
|
||||
# TODO - This does NOT work for some reason? Ref build.views.BuildItemCreate
|
||||
@ -884,7 +904,7 @@ class StockItemCreate(AjaxCreateView):
|
||||
# Otherwise if the user has selected a SupplierPart, we know what Part they meant!
|
||||
elif form['supplier_part'].value() is not None:
|
||||
pass
|
||||
|
||||
|
||||
return form
|
||||
|
||||
def get_initial(self):
|
||||
@ -917,6 +937,7 @@ class StockItemCreate(AjaxCreateView):
|
||||
if part_id:
|
||||
try:
|
||||
part = Part.objects.get(pk=part_id)
|
||||
|
||||
# Check that the supplied part is 'valid'
|
||||
if not part.is_template and part.active and not part.virtual:
|
||||
initials['part'] = part
|
||||
@ -954,6 +975,8 @@ class StockItemCreate(AjaxCreateView):
|
||||
- Manage serial-number valdiation for tracked parts
|
||||
"""
|
||||
|
||||
part = None
|
||||
|
||||
form = self.get_form()
|
||||
|
||||
data = {}
|
||||
@ -965,12 +988,22 @@ class StockItemCreate(AjaxCreateView):
|
||||
try:
|
||||
part = Part.objects.get(id=part_id)
|
||||
quantity = Decimal(form['quantity'].value())
|
||||
|
||||
sn = part.getNextSerialNumber()
|
||||
form.field_placeholder['serial_numbers'] = _("Next available serial number is") + " " + str(sn)
|
||||
|
||||
form.rebuild_layout()
|
||||
|
||||
except (Part.DoesNotExist, ValueError, InvalidOperation):
|
||||
part = None
|
||||
quantity = 1
|
||||
valid = False
|
||||
form.errors['quantity'] = [_('Invalid quantity')]
|
||||
|
||||
if quantity <= 0:
|
||||
form.errors['quantity'] = [_('Quantity must be greater than zero')]
|
||||
valid = False
|
||||
|
||||
if part is None:
|
||||
form.errors['part'] = [_('Invalid part selection')]
|
||||
else:
|
||||
@ -988,7 +1021,7 @@ class StockItemCreate(AjaxCreateView):
|
||||
existing = []
|
||||
|
||||
for serial in serials:
|
||||
if not StockItem.check_serial_number(part, serial):
|
||||
if part.checkIfSerialNumberExists(serial):
|
||||
existing.append(serial)
|
||||
|
||||
if len(existing) > 0:
|
||||
@ -1003,24 +1036,26 @@ class StockItemCreate(AjaxCreateView):
|
||||
|
||||
form_data = form.cleaned_data
|
||||
|
||||
for serial in serials:
|
||||
# Create a new stock item for each serial number
|
||||
item = StockItem(
|
||||
part=part,
|
||||
quantity=1,
|
||||
serial=serial,
|
||||
supplier_part=form_data.get('supplier_part'),
|
||||
location=form_data.get('location'),
|
||||
batch=form_data.get('batch'),
|
||||
delete_on_deplete=False,
|
||||
status=form_data.get('status'),
|
||||
link=form_data.get('link'),
|
||||
)
|
||||
if form.is_valid():
|
||||
|
||||
item.save(user=request.user)
|
||||
for serial in serials:
|
||||
# Create a new stock item for each serial number
|
||||
item = StockItem(
|
||||
part=part,
|
||||
quantity=1,
|
||||
serial=serial,
|
||||
supplier_part=form_data.get('supplier_part'),
|
||||
location=form_data.get('location'),
|
||||
batch=form_data.get('batch'),
|
||||
delete_on_deplete=False,
|
||||
status=form_data.get('status'),
|
||||
link=form_data.get('link'),
|
||||
)
|
||||
|
||||
data['success'] = _('Created {n} new stock items'.format(n=len(serials)))
|
||||
valid = True
|
||||
item.save(user=request.user)
|
||||
|
||||
data['success'] = _('Created {n} new stock items'.format(n=len(serials)))
|
||||
valid = True
|
||||
|
||||
except ValidationError as e:
|
||||
form.errors['serial_numbers'] = e.messages
|
||||
@ -1031,6 +1066,24 @@ class StockItemCreate(AjaxCreateView):
|
||||
form.clean()
|
||||
form._post_clean()
|
||||
|
||||
if form.is_valid():
|
||||
|
||||
item = form.save(commit=False)
|
||||
item.save(user=request.user)
|
||||
|
||||
data['pk'] = item.pk
|
||||
data['url'] = item.get_absolute_url()
|
||||
data['success'] = _("Created new stock item")
|
||||
|
||||
valid = True
|
||||
|
||||
else: # Referenced Part object is not marked as "trackable"
|
||||
# For non-serialized items, simply save the form.
|
||||
# We need to call _post_clean() here because it is prevented in the form implementation
|
||||
form.clean()
|
||||
form._post_clean()
|
||||
|
||||
if form.is_valid:
|
||||
item = form.save(commit=False)
|
||||
item.save(user=request.user)
|
||||
|
||||
@ -1038,20 +1091,9 @@ class StockItemCreate(AjaxCreateView):
|
||||
data['url'] = item.get_absolute_url()
|
||||
data['success'] = _("Created new stock item")
|
||||
|
||||
else: # Referenced Part object is not marked as "trackable"
|
||||
# For non-serialized items, simply save the form.
|
||||
# We need to call _post_clean() here because it is prevented in the form implementation
|
||||
form.clean()
|
||||
form._post_clean()
|
||||
|
||||
item = form.save(commit=False)
|
||||
item.save(user=request.user)
|
||||
valid = True
|
||||
|
||||
data['pk'] = item.pk
|
||||
data['url'] = item.get_absolute_url()
|
||||
data['success'] = _("Created new stock item")
|
||||
|
||||
data['form_valid'] = valid
|
||||
data['form_valid'] = valid and form.is_valid()
|
||||
|
||||
return self.renderJsonResponse(request, form, data=data)
|
||||
|
||||
|
@ -110,6 +110,10 @@ function getAvailableTableFilters(tableKey) {
|
||||
type: 'bool',
|
||||
title: '{% trans "Salable" %}',
|
||||
},
|
||||
trackable: {
|
||||
type: 'bool',
|
||||
title: '{% trans "Trackable" %}',
|
||||
},
|
||||
purchaseable: {
|
||||
type: 'bool',
|
||||
title: '{% trans "Purchasable" %}',
|
||||
|
Loading…
Reference in New Issue
Block a user