diff --git a/InvenTree/InvenTree/helpers.py b/InvenTree/InvenTree/helpers.py
index ddb4e35fee..a34199d38c 100644
--- a/InvenTree/InvenTree/helpers.py
+++ b/InvenTree/InvenTree/helpers.py
@@ -3,12 +3,15 @@ Provides helper functions used throughout the InvenTree project
"""
import io
+import re
import json
import os.path
from PIL import Image
from wsgiref.util import FileWrapper
from django.http import StreamingHttpResponse
+from django.core.exceptions import ValidationError
+from django.utils.translation import ugettext as _
def TestIfImage(img):
@@ -115,3 +118,74 @@ def DownloadFile(data, filename, content_type='application/text'):
response['Content-Disposition'] = 'attachment; filename={f}'.format(f=filename)
return response
+
+
+def ExtractSerialNumbers(serials, expected_quantity):
+ """ Attempt to extract serial numbers from an input string.
+ - Serial numbers must be integer values
+ - Serial numbers must be positive
+ - Serial numbers can be split by whitespace / newline / commma chars
+ - Serial numbers can be supplied as an inclusive range using hyphen char e.g. 10-20
+
+ Args:
+ expected_quantity: The number of (unique) serial numbers we expect
+ """
+
+ groups = re.split("[\s,]+", serials)
+
+ numbers = []
+ errors = []
+
+ for group in groups:
+
+ group = group.strip()
+
+ # Hyphen indicates a range of numbers
+ if '-' in group:
+ items = group.split('-')
+
+ if len(items) == 2:
+ a = items[0].strip()
+ b = items[1].strip()
+
+ try:
+ a = int(a)
+ b = int(b)
+
+ if a < b:
+ for n in range(a, b + 1):
+ if n in numbers:
+ errors.append('Duplicate serial: {n}'.format(n=n))
+ else:
+ numbers.append(n)
+ else:
+ errors.append("Invalid group: {g}".format(g=group))
+
+ except ValueError:
+ errors.append("Invalid group: {g}".format(g=group))
+ continue
+ else:
+ errors.append("Invalid group: {g}".format(g=group))
+ continue
+
+ else:
+ try:
+ n = int(group)
+ if n in numbers:
+ errors.append("Duplicate serial: {n}".format(n=n))
+ else:
+ numbers.append(n)
+ except ValueError:
+ errors.append("Invalid group: {g}".format(g=group))
+
+ if len(errors) > 0:
+ raise ValidationError(errors)
+
+ if len(numbers) == 0:
+ raise ValidationError(["No serial numbers found"])
+
+ # The number of extracted serial numbers must match the expected quantity
+ if not expected_quantity == len(numbers):
+ raise ValidationError([_("Number of unique serial number ({s}) must match quantity ({q})".format(s=len(numbers), q=expected_quantity))])
+
+ return numbers
diff --git a/InvenTree/InvenTree/static/script/inventree/stock.js b/InvenTree/InvenTree/static/script/inventree/stock.js
index 268b8ed42f..189f1249eb 100644
--- a/InvenTree/InvenTree/static/script/inventree/stock.js
+++ b/InvenTree/InvenTree/static/script/inventree/stock.js
@@ -275,7 +275,7 @@ function loadStockTrackingTable(table, options) {
formatter: function(value, row, index, field) {
var m = moment(value);
if (m.isValid()) {
- var html = m.format('dddd MMMM Do YYYY') + ' ' + m.format('h:mm a');
+ var html = m.format('dddd MMMM Do YYYY'); // + ' ' + m.format('h:mm a');
return html;
}
@@ -308,6 +308,10 @@ function loadStockTrackingTable(table, options) {
html += " " + row.notes + "";
}
+ if (row.URL) {
+ html += " " + row.URL + "";
+ }
+
return html;
}
});
@@ -334,6 +338,21 @@ function loadStockTrackingTable(table, options) {
}
});
+ cols.push({
+ sortable: false,
+ formatter: function(value, row, index, field) {
+ // Manually created entries can be edited or deleted
+ if (!row.system) {
+ var bEdit = "";
+ var bDel = "";
+
+ return "
" + bEdit + bDel + "
";
+ } else {
+ return "";
+ }
+ }
+ });
+
table.bootstrapTable({
sortable: true,
search: true,
@@ -349,4 +368,20 @@ function loadStockTrackingTable(table, options) {
if (options.buttons) {
linkButtonsToSelection(table, options.buttons);
}
+
+ table.on('click', '.btn-entry-edit', function() {
+ var button = $(this);
+
+ launchModalForm(button.attr('url'), {
+ reload: true,
+ });
+ });
+
+ table.on('click', '.btn-entry-delete', function() {
+ var button = $(this);
+
+ launchModalForm(button.attr('url'), {
+ reload: true,
+ });
+ });
}
\ No newline at end of file
diff --git a/InvenTree/build/forms.py b/InvenTree/build/forms.py
index 01024230f4..101903e1e8 100644
--- a/InvenTree/build/forms.py
+++ b/InvenTree/build/forms.py
@@ -48,11 +48,14 @@ class CompleteBuildForm(HelperForm):
help_text='Location of completed parts',
)
+ serial_numbers = forms.CharField(label='Serial numbers', help_text='Enter unique serial numbers')
+
confirm = forms.BooleanField(required=False, help_text='Confirm build submission')
class Meta:
model = Build
fields = [
+ 'serial_numbers',
'location',
'confirm'
]
diff --git a/InvenTree/build/models.py b/InvenTree/build/models.py
index 2825731efc..1ceedf63e2 100644
--- a/InvenTree/build/models.py
+++ b/InvenTree/build/models.py
@@ -199,7 +199,7 @@ class Build(models.Model):
build_item.save()
@transaction.atomic
- def completeBuild(self, location, user):
+ def completeBuild(self, location, serial_numbers, user):
""" Mark the Build as COMPLETE
- Takes allocated items from stock
@@ -227,19 +227,36 @@ class Build(models.Model):
self.completed_by = user
- # Add stock of the newly created item
- item = StockItem.objects.create(
- part=self.part,
- location=location,
- quantity=self.quantity,
- batch=str(self.batch) if self.batch else '',
- notes='Built {q} on {now}'.format(
- q=self.quantity,
- now=str(datetime.now().date())
- )
+ notes = 'Built {q} on {now}'.format(
+ q=self.quantity,
+ now=str(datetime.now().date())
)
- item.save()
+ if self.part.trackable:
+ # Add new serial numbers
+ for serial in serial_numbers:
+ item = StockItem.objects.create(
+ part=self.part,
+ location=location,
+ quantity=1,
+ serial=serial,
+ batch=str(self.batch) if self.batch else '',
+ notes=notes
+ )
+
+ item.save()
+
+ else:
+ # Add stock of the newly created item
+ item = StockItem.objects.create(
+ part=self.part,
+ location=location,
+ quantity=self.quantity,
+ batch=str(self.batch) if self.batch else '',
+ notes=notes
+ )
+
+ item.save()
# Finally, mark the build as complete
self.status = BuildStatus.COMPLETE
diff --git a/InvenTree/build/views.py b/InvenTree/build/views.py
index 0e7d73d294..db276ed88d 100644
--- a/InvenTree/build/views.py
+++ b/InvenTree/build/views.py
@@ -5,6 +5,8 @@ Django views for interacting with Build objects
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
+from django.utils.translation import ugettext as _
+from django.core.exceptions import ValidationError
from django.views.generic import DetailView, ListView
from django.forms import HiddenInput
@@ -14,7 +16,7 @@ from . import forms
from stock.models import StockLocation, StockItem
from InvenTree.views import AjaxUpdateView, AjaxCreateView, AjaxDeleteView
-from InvenTree.helpers import str2bool
+from InvenTree.helpers import str2bool, ExtractSerialNumbers
from InvenTree.status_codes import BuildStatus
@@ -182,6 +184,20 @@ class BuildComplete(AjaxUpdateView):
ajax_form_title = "Complete Build"
ajax_template_name = "build/complete.html"
+ def get_form(self):
+ """ Get the form object.
+
+ If the part is trackable, include a field for serial numbers.
+ """
+ build = self.get_object()
+
+ form = super().get_form()
+
+ if not build.part.trackable:
+ form.fields.pop('serial_numbers')
+
+ return form
+
def get_initial(self):
""" Get initial form data for the CompleteBuild form
@@ -206,10 +222,11 @@ class BuildComplete(AjaxUpdateView):
- Build information is required
"""
- build = self.get_object()
+ build = Build.objects.get(id=self.kwargs['pk'])
+
+ context = {}
# Build object
- context = super(BuildComplete, self).get_context_data(**kwargs).copy()
context['build'] = build
# Items to be removed from stock
@@ -246,14 +263,40 @@ class BuildComplete(AjaxUpdateView):
except StockLocation.DoesNotExist:
form.errors['location'] = ['Invalid location selected']
+ serials = []
+
+ if build.part.trackable:
+ # A build for a trackable part must specify serial numbers
+
+ sn = request.POST.get('serial_numbers', '')
+
+ try:
+ # Exctract a list of provided serial numbers
+ serials = ExtractSerialNumbers(sn, build.quantity)
+
+ existing = []
+
+ for serial in serials:
+ if not StockItem.check_serial_number(build.part, serial):
+ existing.append(serial)
+
+ if len(existing) > 0:
+ exists = ",".join([str(x) for x in existing])
+ form.errors['serial_numbers'] = [_('The following serial numbers already exist: ({sn})'.format(sn=exists))]
+ valid = False
+
+ except ValidationError as e:
+ form.errors['serial_numbers'] = e.messages
+ valid = False
+
if valid:
- build.completeBuild(location, request.user)
+ build.completeBuild(location, serials, request.user)
data = {
'form_valid': valid,
}
- return self.renderJsonResponse(request, form, data)
+ return self.renderJsonResponse(request, form, data, context=self.get_context_data())
def get_data(self):
""" Provide feedback data back to the form """
diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html
index ddd917edc0..294125842e 100644
--- a/InvenTree/part/templates/part/detail.html
+++ b/InvenTree/part/templates/part/detail.html
@@ -116,9 +116,9 @@
Trackable
{% include "slide.html" with state=part.trackable field='trackable' %}
There are {{ part.locations.all|length }} stock entries defined for this part. If you delete this part, the following stock entries will also be deleted:
+
There are {{ part.stock_items.all|length }} stock entries defined for this part. If you delete this part, the following stock entries will also be deleted:
diff --git a/InvenTree/stock/forms.py b/InvenTree/stock/forms.py
index d7d74e45de..9e24b538f3 100644
--- a/InvenTree/stock/forms.py
+++ b/InvenTree/stock/forms.py
@@ -6,9 +6,10 @@ Django Forms for interacting with Stock app
from __future__ import unicode_literals
from django import forms
-from InvenTree.forms import HelperForm
+from django.forms.utils import ErrorDict
-from .models import StockLocation, StockItem
+from InvenTree.forms import HelperForm
+from .models import StockLocation, StockItem, StockItemTracking
class EditStockLocationForm(HelperForm):
@@ -26,6 +27,8 @@ class EditStockLocationForm(HelperForm):
class CreateStockItemForm(HelperForm):
""" Form for creating a new StockItem """
+ serial_numbers = forms.CharField(label='Serial numbers', required=False, help_text='Enter unique serial numbers')
+
class Meta:
model = StockItem
fields = [
@@ -34,13 +37,30 @@ class CreateStockItemForm(HelperForm):
'location',
'quantity',
'batch',
- 'serial',
+ 'serial_numbers',
'delete_on_deplete',
'status',
'notes',
'URL',
]
+ # Custom clean to prevent complex StockItem.clean() logic from running (yet)
+ def full_clean(self):
+ self._errors = ErrorDict()
+
+ if not self.is_bound: # Stop further processing.
+ 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():
+ return
+
+ # Don't run _post_clean() as this will run StockItem.clean()
+ self._clean_fields()
+ self._clean_form()
+
class AdjustStockForm(forms.ModelForm):
""" Form for performing simple stock adjustments.
@@ -104,3 +124,17 @@ class EditStockItemForm(HelperForm):
'notes',
'URL',
]
+
+
+class TrackingEntryForm(HelperForm):
+ """ Form for creating / editing a StockItemTracking object.
+ """
+
+ class Meta:
+ model = StockItemTracking
+
+ fields = [
+ 'title',
+ 'notes',
+ 'URL',
+ ]
diff --git a/InvenTree/stock/migrations/0008_stockitemtracking_url.py b/InvenTree/stock/migrations/0008_stockitemtracking_url.py
new file mode 100644
index 0000000000..e27f7f8854
--- /dev/null
+++ b/InvenTree/stock/migrations/0008_stockitemtracking_url.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.2.2 on 2019-07-15 11:10
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('stock', '0007_auto_20190618_0042'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='stockitemtracking',
+ name='URL',
+ field=models.URLField(blank=True, help_text='Link to external page for further information'),
+ ),
+ ]
diff --git a/InvenTree/stock/migrations/0009_auto_20190715_2351.py b/InvenTree/stock/migrations/0009_auto_20190715_2351.py
new file mode 100644
index 0000000000..230ca68401
--- /dev/null
+++ b/InvenTree/stock/migrations/0009_auto_20190715_2351.py
@@ -0,0 +1,23 @@
+# Generated by Django 2.2.2 on 2019-07-15 13:51
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('stock', '0008_stockitemtracking_url'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='stockitemtracking',
+ name='notes',
+ field=models.CharField(blank=True, help_text='Entry notes', max_length=512),
+ ),
+ migrations.AlterField(
+ model_name='stockitemtracking',
+ name='title',
+ field=models.CharField(help_text='Tracking entry title', max_length=250),
+ ),
+ ]
diff --git a/InvenTree/stock/models.py b/InvenTree/stock/models.py
index 34dcd00464..4f4c74d6a2 100644
--- a/InvenTree/stock/models.py
+++ b/InvenTree/stock/models.py
@@ -92,6 +92,7 @@ class StockItem(models.Model):
location: Where this StockItem is located
quantity: Number of stocked units
batch: Batch number for this StockItem
+ serial: Unique serial number for this StockItem
URL: Optional URL to link to external resource
updated: Date that this stock item was last updated (auto)
stocktake_date: Date of last stocktake for this item
@@ -121,6 +122,31 @@ class StockItem(models.Model):
system=True
)
+ @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
+ """
+
+ if not part.trackable:
+ 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)
@@ -129,11 +155,18 @@ class StockItem(models.Model):
# 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))
- })
+ 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 part with this serial number already exists for template part {part}'.format(part=self.part.variant_of))
+ })
+ else:
+ if StockItem.objects.filter(serial=self.serial).exclude(id=self.id).exists():
+ raise ValidationError({
+ 'serial': _('A part with this serial number already exists')
+ })
except Part.DoesNotExist:
pass
@@ -158,16 +191,23 @@ 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')
- })
+ if self.part.trackable:
+ if not self.serial:
+ raise ValidationError({'serial': _('Serial number must be set for trackable items')})
+
+ if self.delete_on_deplete:
+ raise ValidationError({'delete_on_deplete': _("Must be set to False for trackable items")})
+
+ # Serial number cannot be set for items with quantity greater than 1
+ if not self.quantity == 1:
+ raise ValidationError({
+ 'quantity': _("Quantity must be set to 1 for item with a serial number"),
+ 'serial': _("Serial number cannot be set if quantity > 1")
+ })
# 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')
- })
+ raise ValidationError({'part': _('Stock item cannot be created for a template Part')})
except Part.DoesNotExist:
# This gets thrown if self.supplier_part is null
@@ -179,13 +219,6 @@ class StockItem(models.Model):
'belongs_to': _('Item cannot belong to itself')
})
- # Serial number cannot be set for items with quantity greater than 1
- if not self.quantity == 1 and self.serial:
- raise ValidationError({
- 'quantity': _("Quantity must be set to 1 for item with a serial number"),
- 'serial': _("Serial number cannot be set if quantity > 1")
- })
-
def get_absolute_url(self):
return reverse('stock-item-detail', kwargs={'pk': self.id})
@@ -298,7 +331,7 @@ class StockItem(models.Model):
def has_tracking_info(self):
return self.tracking_info.count() > 0
- def addTransactionNote(self, title, user, notes='', system=True):
+ def addTransactionNote(self, title, user, notes='', url='', system=True):
""" Generation a stock transaction note for this item.
Brief automated note detailing a movement or quantity change.
@@ -310,6 +343,7 @@ class StockItem(models.Model):
quantity=self.quantity,
date=datetime.now().date(),
notes=notes,
+ URL=url,
system=system
)
@@ -494,9 +528,14 @@ class StockItem(models.Model):
return True
def __str__(self):
- s = '{n} x {part}'.format(
- n=self.quantity,
- part=self.part.full_name)
+ if self.part.trackable and self.serial:
+ s = '{part} #{sn}'.format(
+ part=self.part.full_name,
+ sn=self.serial)
+ else:
+ s = '{n} x {part}'.format(
+ n=self.quantity,
+ part=self.part.full_name)
if self.location:
s += ' @ {loc}'.format(loc=self.location.name)
@@ -512,6 +551,7 @@ class StockItemTracking(models.Model):
date: Date that this tracking info was created
title: Title of this tracking info (generated by system)
notes: Associated notes (input by user)
+ URL: Optional URL to external page
user: The user associated with this tracking info
quantity: The StockItem quantity at this point in time
"""
@@ -525,9 +565,11 @@ class StockItemTracking(models.Model):
date = models.DateTimeField(auto_now_add=True, editable=False)
- title = models.CharField(blank=False, max_length=250)
+ title = models.CharField(blank=False, max_length=250, help_text='Tracking entry title')
- notes = models.TextField(blank=True)
+ notes = models.CharField(blank=True, max_length=512, help_text='Entry notes')
+
+ URL = models.URLField(blank=True, help_text='Link to external page for further information')
user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True)
diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py
index 4ec0530bf4..14fe043564 100644
--- a/InvenTree/stock/serializers.py
+++ b/InvenTree/stock/serializers.py
@@ -149,6 +149,7 @@ class StockTrackingSerializer(InvenTreeModelSerializer):
'date',
'title',
'notes',
+ 'URL',
'quantity',
'user',
'system',
diff --git a/InvenTree/stock/templates/stock/item.html b/InvenTree/stock/templates/stock/item.html
index 28c54965bc..f6847d5ae1 100644
--- a/InvenTree/stock/templates/stock/item.html
+++ b/InvenTree/stock/templates/stock/item.html
@@ -125,19 +125,29 @@
-
-{% if item.has_tracking_info %}
-