diff --git a/InvenTree/part/forms.py b/InvenTree/part/forms.py
index e069df6ed6..cbc1cdaf8c 100644
--- a/InvenTree/part/forms.py
+++ b/InvenTree/part/forms.py
@@ -93,6 +93,8 @@ class EditPartForm(HelperForm):
'name',
'IPN',
'variant',
+ 'is_template',
+ 'variant_of',
'description',
'keywords',
'URL',
diff --git a/InvenTree/part/migrations/0003_auto_20190525_2226.py b/InvenTree/part/migrations/0003_auto_20190525_2226.py
new file mode 100644
index 0000000000..3e805d460d
--- /dev/null
+++ b/InvenTree/part/migrations/0003_auto_20190525_2226.py
@@ -0,0 +1,24 @@
+# Generated by Django 2.2 on 2019-05-25 12:26
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('part', '0002_auto_20190520_2204'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='part',
+ name='has_variants',
+ field=models.BooleanField(default=False, help_text='Is this part a template part?'),
+ ),
+ migrations.AddField(
+ model_name='part',
+ name='variant_of',
+ field=models.ForeignKey(blank=True, help_text='Is this part a variant of another part?', limit_choices_to={'active': True, 'has_variants': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='variants', to='part.Part'),
+ ),
+ ]
diff --git a/InvenTree/part/migrations/0004_auto_20190525_2356.py b/InvenTree/part/migrations/0004_auto_20190525_2356.py
new file mode 100644
index 0000000000..7b9a5fe6ac
--- /dev/null
+++ b/InvenTree/part/migrations/0004_auto_20190525_2356.py
@@ -0,0 +1,24 @@
+# Generated by Django 2.2 on 2019-05-25 13:56
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('part', '0003_auto_20190525_2226'),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name='part',
+ old_name='has_variants',
+ new_name='is_template',
+ ),
+ migrations.AlterField(
+ model_name='part',
+ name='variant_of',
+ field=models.ForeignKey(blank=True, help_text='Is this part a variant of another part?', limit_choices_to={'active': True, 'is_template': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='variants', to='part.Part'),
+ ),
+ ]
diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index 385bae7bf4..3b9a7aea60 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -192,6 +192,7 @@ class Part(models.Model):
description: Longer form description of the part
keywords: Optional keywords for improving part search results
IPN: Internal part number (optional)
+ is_template: If True, this part is a 'template' part and cannot be instantiated as a StockItem
URL: Link to an external page with more information about this part (e.g. internal Wiki)
image: Image of this part
default_location: Where the item is normally stored (may be null)
@@ -252,12 +253,32 @@ class Part(models.Model):
else:
return static('/img/blank_image.png')
+ def clean(self):
+ """ Perform cleaning operations for the Part model """
+
+ if self.is_template and self.variant_of is not None:
+ raise ValidationError({
+ 'is_template': _("Part cannot be a template part if it is a variant of another part"),
+ 'variant_of': _("Part cannot be a variant of another part if it is already a template"),
+ })
+
name = models.CharField(max_length=100, blank=False, help_text='Part name',
validators=[validators.validate_part_name]
)
variant = models.CharField(max_length=32, blank=True, help_text='Part variant or revision code')
+ is_template = models.BooleanField(default=False, help_text='Is this part a template part?')
+
+ variant_of = models.ForeignKey('part.Part', related_name='variants',
+ null=True, blank=True,
+ limit_choices_to={
+ 'is_template': True,
+ 'active': True,
+ },
+ on_delete=models.SET_NULL,
+ help_text='Is this part a variant of another part?')
+
description = models.CharField(max_length=250, blank=False, help_text='Part description')
keywords = models.CharField(max_length=250, blank=True, help_text='Part keywords to improve visibility in search results')
@@ -501,7 +522,10 @@ class Part(models.Model):
Part may be stored in multiple locations
"""
- total = self.stock_entries.aggregate(total=Sum('quantity'))['total']
+ if self.is_template:
+ total = sum([variant.total_stock for variant in self.variants.all()])
+ else:
+ total = self.stock_entries.aggregate(total=Sum('quantity'))['total']
if total:
return total
@@ -747,6 +771,21 @@ class Part(models.Model):
return data.export(file_format)
+ @property
+ def attachment_count(self):
+ """ Count the number of attachments for this part.
+ If the part is a variant of a template part,
+ include the number of attachments for the template part.
+
+ """
+
+ n = self.attachments.count()
+
+ if self.variant_of:
+ n += self.variant_of.attachments.count()
+
+ return n
+
def attach_file(instance, filename):
""" Function for storing a file for a PartAttachment
diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py
index 445a10601a..540f754765 100644
--- a/InvenTree/part/serializers.py
+++ b/InvenTree/part/serializers.py
@@ -85,6 +85,8 @@ class PartSerializer(serializers.ModelSerializer):
'full_name',
'name',
'IPN',
+ 'is_template',
+ 'variant_of',
'variant',
'description',
'keywords',
diff --git a/InvenTree/part/templates/part/attachments.html b/InvenTree/part/templates/part/attachments.html
index d0ccaf122d..493002d2f0 100644
--- a/InvenTree/part/templates/part/attachments.html
+++ b/InvenTree/part/templates/part/attachments.html
@@ -30,12 +30,26 @@
{{ attachment.comment }}
-
+
{% endfor %}
+{% if part.variant_of and part.variant_of.attachments.count > 0 %}
+
+
+ Attachments for template part {{ part.variant_of.full_name }}
+
+
+{% for attachment in part.variant_of.attachments.all %}
+
";
diff --git a/InvenTree/static/script/inventree/build.js b/InvenTree/static/script/inventree/build.js
index e3d1238e5e..0a9250ebb2 100644
--- a/InvenTree/static/script/inventree/build.js
+++ b/InvenTree/static/script/inventree/build.js
@@ -40,7 +40,7 @@ function loadAllocationTable(table, part_id, part, url, required, button) {
formatter: function(value, row, index, field) {
var html = value;
- var bEdit = "";
+ var bEdit = "";
var bDel = "";
html += "
" + bEdit + bDel + "
";
diff --git a/InvenTree/static/script/inventree/part.js b/InvenTree/static/script/inventree/part.js
index 51d86f1c7a..1001ef11a1 100644
--- a/InvenTree/static/script/inventree/part.js
+++ b/InvenTree/static/script/inventree/part.js
@@ -124,7 +124,12 @@ function loadPartTable(table, url, options={}) {
sortable: true,
formatter: function(value, row, index, field) {
+ if (row.is_template) {
+ value = '' + value + '';
+ }
+
var display = imageHoverIcon(row.image_url) + renderLink(value, row.url);
+
if (!row.active) {
display = display + "INACTIVE";
}
@@ -135,6 +140,14 @@ function loadPartTable(table, url, options={}) {
sortable: true,
field: 'description',
title: 'Description',
+ formatter: function(value, row, index, field) {
+
+ if (row.is_template) {
+ value = '' + value + '';
+ }
+
+ return value;
+ }
},
{
sortable: true,
diff --git a/InvenTree/stock/api.py b/InvenTree/stock/api.py
index 0bff4a3873..ec998dde41 100644
--- a/InvenTree/stock/api.py
+++ b/InvenTree/stock/api.py
@@ -11,7 +11,7 @@ from django.urls import reverse
from .models import StockLocation, StockItem
from .models import StockItemTracking
-from part.models import PartCategory
+from part.models import Part, PartCategory
from .serializers import StockItemSerializer, StockQuantitySerializer
from .serializers import LocationSerializer
@@ -263,12 +263,28 @@ class StockList(generics.ListCreateAPIView):
we may wish to also request stock items from all child locations.
"""
- # Does the client wish to filter by stock location?
- loc_id = self.request.query_params.get('location', None)
-
# Start with all objects
stock_list = StockItem.objects.all()
+ # Does the client wish to filter by the Part ID?
+ part_id = self.request.query_params.get('part', None)
+
+ if part_id:
+ try:
+ part = Part.objects.get(pk=part_id)
+
+ # If the part is a Template part, select stock items for any "variant" parts under that template
+ if part.is_template:
+ stock_list = stock_list.filter(part__in=[part.id for part in Part.objects.filter(variant_of=part_id)])
+ else:
+ stock_list = stock_list.filter(part=part_id)
+
+ except Part.DoesNotExist:
+ pass
+
+ # Does the client wish to filter by stock location?
+ loc_id = self.request.query_params.get('location', None)
+
if loc_id:
try:
location = StockLocation.objects.get(pk=loc_id)
@@ -312,7 +328,6 @@ class StockList(generics.ListCreateAPIView):
]
filter_fields = [
- 'part',
'supplier_part',
'customer',
'belongs_to',
diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py
index ee8592bbb9..a05c8caef0 100644
--- a/InvenTree/stock/forms.py
+++ b/InvenTree/stock/forms.py
@@ -34,6 +34,7 @@ class CreateStockItemForm(HelperForm):
'location',
'quantity',
'batch',
+ 'serial',
'delete_on_deplete',
'status',
'notes',
diff --git a/InvenTree/stock/migrations/0002_auto_20190525_2226.py b/InvenTree/stock/migrations/0002_auto_20190525_2226.py
new file mode 100644
index 0000000000..b8607c3ec7
--- /dev/null
+++ b/InvenTree/stock/migrations/0002_auto_20190525_2226.py
@@ -0,0 +1,19 @@
+# Generated by Django 2.2 on 2019-05-25 12:26
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('stock', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='stockitem',
+ name='part',
+ field=models.ForeignKey(help_text='Base part', limit_choices_to={'active': True, 'has_variants': True}, on_delete=django.db.models.deletion.CASCADE, related_name='stock_items', to='part.Part'),
+ ),
+ ]
diff --git a/InvenTree/stock/migrations/0003_auto_20190525_2303.py b/InvenTree/stock/migrations/0003_auto_20190525_2303.py
new file mode 100644
index 0000000000..e8f7708cfe
--- /dev/null
+++ b/InvenTree/stock/migrations/0003_auto_20190525_2303.py
@@ -0,0 +1,19 @@
+# Generated by Django 2.2 on 2019-05-25 13:03
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('stock', '0002_auto_20190525_2226'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='stockitem',
+ name='part',
+ field=models.ForeignKey(help_text='Base part', limit_choices_to={'active': True, 'has_variants': False}, on_delete=django.db.models.deletion.CASCADE, related_name='stock_items', to='part.Part'),
+ ),
+ ]
diff --git a/InvenTree/stock/migrations/0004_auto_20190525_2356.py b/InvenTree/stock/migrations/0004_auto_20190525_2356.py
new file mode 100644
index 0000000000..4b2f939eb2
--- /dev/null
+++ b/InvenTree/stock/migrations/0004_auto_20190525_2356.py
@@ -0,0 +1,19 @@
+# Generated by Django 2.2 on 2019-05-25 13:56
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('stock', '0003_auto_20190525_2303'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='stockitem',
+ name='part',
+ field=models.ForeignKey(help_text='Base part', limit_choices_to={'active': True, 'is_template': False}, on_delete=django.db.models.deletion.CASCADE, related_name='stock_items', to='part.Part'),
+ ),
+ ]
diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py
index 3680079b30..f6651f6dd4 100644
--- a/InvenTree/stock/models.py
+++ b/InvenTree/stock/models.py
@@ -115,6 +115,22 @@ class StockItem(models.Model):
system=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
+
+ try:
+ if self.serial is not None and 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 part with this serial number already exists for template part {part}'.format(part=self.part.variant_of))
+ })
+ except Part.DoesNotExist:
+ pass
+
def clean(self):
""" Validate the StockItem object (separate to field validation)
@@ -135,11 +151,18 @@ class StockItem(models.Model):
})
if self.part is not None:
+ # A trackable part must have a serial number
if self.part.trackable and not self.serial:
raise ValidationError({
'serial': _('Serial number must be set for trackable items')
})
+ # A template part cannot be instantiated as a StockItem
+ if self.part.is_template:
+ raise ValidationError({
+ 'part': _('Stock item cannot be created for a template Part')
+ })
+
except Part.DoesNotExist:
# This gets thrown if self.supplier_part is null
# TODO - Find a test than can be perfomed...
@@ -186,7 +209,12 @@ class StockItem(models.Model):
}
)
- part = models.ForeignKey('part.Part', on_delete=models.CASCADE, related_name='stock_items', help_text='Base part')
+ part = models.ForeignKey('part.Part', on_delete=models.CASCADE,
+ related_name='stock_items', help_text='Base part',
+ limit_choices_to={
+ 'is_template': False,
+ 'active': True,
+ })
supplier_part = models.ForeignKey('company.SupplierPart', blank=True, null=True, on_delete=models.SET_NULL,
help_text='Select a matching supplier part for this stock item')
diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py
index cd9fb615af..42ca16d3ba 100644
--- a/InvenTree/stock/views.py
+++ b/InvenTree/stock/views.py
@@ -208,28 +208,31 @@ class StockItemCreate(AjaxCreateView):
try:
part = Part.objects.get(id=part_id)
- parts = form.fields['supplier_part'].queryset
- parts = parts.filter(part=part.id)
+
+ # Hide the 'part' field (as a valid part is selected)
+ form.fields['part'].widget = HiddenInput()
# If the part is NOT purchaseable, hide the supplier_part field
if not part.purchaseable:
form.fields['supplier_part'].widget = HiddenInput()
- form.fields['supplier_part'].queryset = parts
+ else:
+ # Pre-select the allowable SupplierPart options
+ parts = form.fields['supplier_part'].queryset
+ parts = parts.filter(part=part.id)
- # If there is one (and only one) supplier part available, pre-select it
- all_parts = parts.all()
- if len(all_parts) == 1:
+ form.fields['supplier_part'].queryset = parts
- # TODO - This does NOT work for some reason? Ref build.views.BuildItemCreate
- form.fields['supplier_part'].initial = all_parts[0].id
+ # 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
+ form.fields['supplier_part'].initial = all_parts[0].id
except Part.DoesNotExist:
pass
- # Hide the 'part' field
- form.fields['part'].widget = HiddenInput()
-
# Otherwise if the user has selected a SupplierPart, we know what Part they meant!
elif form['supplier_part'].value() is not None:
pass
diff --git a/InvenTree/templates/stock_table.html b/InvenTree/templates/stock_table.html
index f5411fabb8..fae7b04214 100644
--- a/InvenTree/templates/stock_table.html
+++ b/InvenTree/templates/stock_table.html
@@ -1,6 +1,8 @@