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' %} {% if part.trackable %} - Part stock will be tracked by (serial or batch) + Part stock is tracked by serial number {% else %} - Part stock will not be tracked by + Part stock is not tracked by serial number {% endif %} diff --git a/InvenTree/part/templates/part/partial_delete.html b/InvenTree/part/templates/part/partial_delete.html index b6516057bb..7b51bf2e89 100644 --- a/InvenTree/part/templates/part/partial_delete.html +++ b/InvenTree/part/templates/part/partial_delete.html @@ -16,12 +16,12 @@

{% endif %} -{% if part.locations.all|length > 0 %} +{% if part.stock_items.all|length > 0 %}
-

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 %}
-
-

Stock Tracking Information

+

Stock Tracking Information

+
+
+ +
-{% endif %} + {% endblock %} {% block js_ready %} {{ block.super }} + $("#new-entry").click(function() { + launchModalForm( + "{% url 'stock-tracking-create' item.id %}", + { + reload: true, + } + ); + }); + $("#stock-duplicate").click(function() { launchModalForm( "{% url 'stock-item-create' %}", @@ -152,11 +162,12 @@ $("#stock-edit").click(function () { launchModalForm( - "{% url 'stock-item-edit' item.id %}", - { - reload: true, - submit_text: "Save", - }); + "{% url 'stock-item-edit' item.id %}", + { + reload: true, + submit_text: "Save", + } + ); }); $("#show-qr-code").click(function() { diff --git a/InvenTree/stock/templates/stock/stock_app_base.html b/InvenTree/stock/templates/stock/stock_app_base.html index b7bf307118..754ffd2354 100644 --- a/InvenTree/stock/templates/stock/stock_app_base.html +++ b/InvenTree/stock/templates/stock/stock_app_base.html @@ -35,7 +35,6 @@ InvenTree | Stock "#stock-tree", { name: 'stock', - selected: 'elab', } ); diff --git a/InvenTree/stock/templates/stock/tracking_delete.html b/InvenTree/stock/templates/stock/tracking_delete.html new file mode 100644 index 0000000000..b5dde45de2 --- /dev/null +++ b/InvenTree/stock/templates/stock/tracking_delete.html @@ -0,0 +1,9 @@ +{% extends "modal_delete_form.html" %} + +{% block pre_form_content %} + +
+Are you sure you want to delete this stock tracking entry? +
+ +{% endblock %} \ No newline at end of file diff --git a/InvenTree/stock/urls.py b/InvenTree/stock/urls.py index 4666eb05dc..9e17d568cf 100644 --- a/InvenTree/stock/urls.py +++ b/InvenTree/stock/urls.py @@ -21,9 +21,23 @@ stock_item_detail_urls = [ url(r'^delete/?', views.StockItemDelete.as_view(), name='stock-item-delete'), url(r'^qr_code/?', views.StockItemQRCode.as_view(), name='stock-item-qr'), + url(r'^add_tracking/?', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'), + url('^.*$', views.StockItemDetail.as_view(), name='stock-item-detail'), ] +stock_tracking_urls = [ + + # edit + url(r'^(?P\d+)/edit/', views.StockItemTrackingEdit.as_view(), name='stock-tracking-edit'), + + # delete + url(r'^(?P\d+)/delete', views.StockItemTrackingDelete.as_view(), name='stock-tracking-delete'), + + # list + url('^.*$', views.StockTrackingIndex.as_view(), name='stock-tracking-list') +] + stock_urls = [ # Stock location url(r'^location/(?P\d+)/', include(stock_location_detail_urls)), @@ -32,7 +46,7 @@ stock_urls = [ url(r'^item/new/?', views.StockItemCreate.as_view(), name='stock-item-create'), - url(r'^track/?', views.StockTrackingIndex.as_view(), name='stock-tracking-list'), + url(r'^track/', include(stock_tracking_urls)), url(r'^adjust/?', views.StockAdjust.as_view(), name='stock-adjust'), diff --git a/InvenTree/stock/views.py b/InvenTree/stock/views.py index 3ede00697e..7595c550b0 100644 --- a/InvenTree/stock/views.py +++ b/InvenTree/stock/views.py @@ -5,6 +5,7 @@ Django views for interacting with Stock app # -*- coding: utf-8 -*- from __future__ import unicode_literals +from django.core.exceptions import ValidationError from django.views.generic.edit import FormMixin from django.views.generic import DetailView, ListView from django.forms.models import model_to_dict @@ -17,6 +18,8 @@ from InvenTree.views import AjaxUpdateView, AjaxDeleteView, AjaxCreateView from InvenTree.views import QRCodeView from InvenTree.helpers import str2bool +from InvenTree.helpers import ExtractSerialNumbers +from datetime import datetime from part.models import Part from .models import StockItem, StockLocation, StockItemTracking @@ -25,6 +28,7 @@ from .forms import EditStockLocationForm from .forms import CreateStockItemForm from .forms import EditStockItemForm from .forms import AdjustStockForm +from .forms import TrackingEntryForm class StockIndex(ListView): @@ -474,7 +478,7 @@ class StockItemCreate(AjaxCreateView): ForeignKey choices based on other selections """ - form = super(AjaxCreateView, self).get_form() + form = super().get_form() # If the user has selected a Part, limit choices for SupplierPart if form['part'].value(): @@ -486,10 +490,16 @@ class StockItemCreate(AjaxCreateView): # Hide the 'part' field (as a valid part is selected) form.fields['part'].widget = HiddenInput() + # trackable parts get special consideration + if part.trackable: + form.fields['delete_on_deplete'].widget = HiddenInput() + form.fields['delete_on_deplete'].initial = False + else: + form.fields.pop('serial_numbers') + # If the part is NOT purchaseable, hide the supplier_part field if not part.purchaseable: form.fields['supplier_part'].widget = HiddenInput() - else: # Pre-select the allowable SupplierPart options parts = form.fields['supplier_part'].queryset @@ -553,6 +563,87 @@ class StockItemCreate(AjaxCreateView): return initials + def post(self, request, *args, **kwargs): + """ Handle POST of StockItemCreate form. + + - Manage serial-number valdiation for tracked parts + """ + + form = self.get_form() + + valid = form.is_valid() + + if valid: + part_id = form['part'].value() + try: + part = Part.objects.get(id=part_id) + quantity = int(form['quantity'].value()) + except (Part.DoesNotExist, ValueError): + part = None + quantity = 1 + valid = False + + if part is None: + form.errors['part'] = [_('Invalid part selection')] + else: + # A trackable part must provide serial numbesr + if part.trackable: + sn = request.POST.get('serial_numbers', '') + + try: + serials = ExtractSerialNumbers(sn, quantity) + + existing = [] + + for serial in serials: + if not StockItem.check_serial_number(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 + + # At this point we have a list of serial numbers which we know are valid, + # and do not currently exist + form.clean() + + 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=data.get('supplier_part'), + location=data.get('location'), + batch=data.get('batch'), + delete_on_deplete=False, + status=data.get('status'), + notes=data.get('notes'), + URL=data.get('URL'), + ) + + item.save() + + except ValidationError as e: + form.errors['serial_numbers'] = e.messages + valid = False + + else: + # 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() + form.save() + + data = { + 'form_valid': valid, + } + + return self.renderJsonResponse(request, form, data=data) + class StockLocationDelete(AjaxDeleteView): """ @@ -580,6 +671,17 @@ class StockItemDelete(AjaxDeleteView): ajax_form_title = 'Delete Stock Item' +class StockItemTrackingDelete(AjaxDeleteView): + """ + View to delete a StockItemTracking object + Presents a deletion confirmation form to the user + """ + + model = StockItemTracking + ajax_template_name = 'stock/tracking_delete.html' + ajax_form_title = 'Delete Stock Tracking Entry' + + class StockTrackingIndex(ListView): """ StockTrackingIndex provides a page to display StockItemTracking objects @@ -588,3 +690,55 @@ class StockTrackingIndex(ListView): model = StockItemTracking template_name = 'stock/tracking.html' context_object_name = 'items' + + +class StockItemTrackingEdit(AjaxUpdateView): + """ View for editing a StockItemTracking object """ + + model = StockItemTracking + ajax_form_title = 'Edit Stock Tracking Entry' + form_class = TrackingEntryForm + + +class StockItemTrackingCreate(AjaxCreateView): + """ View for creating a new StockItemTracking object. + """ + + model = StockItemTracking + ajax_form_title = "Add Stock Tracking Entry" + form_class = TrackingEntryForm + + def post(self, request, *args, **kwargs): + + self.request = request + self.form = self.get_form() + + valid = False + + if self.form.is_valid(): + stock_id = self.kwargs['pk'] + + if stock_id: + try: + stock_item = StockItem.objects.get(id=stock_id) + + # Save new tracking information + tracking = self.form.save(commit=False) + tracking.item = stock_item + tracking.user = self.request.user + tracking.quantity = stock_item.quantity + tracking.date = datetime.now().date() + tracking.system = False + + tracking.save() + + valid = True + + except (StockItem.DoesNotExist, ValueError): + pass + + data = { + 'form_valid': valid + } + + return self.renderJsonResponse(request, self.form, data=data)