Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2019-07-23 12:07:13 +10:00
commit 163f076565
17 changed files with 543 additions and 66 deletions

View File

@ -3,12 +3,15 @@ Provides helper functions used throughout the InvenTree project
""" """
import io import io
import re
import json import json
import os.path import os.path
from PIL import Image from PIL import Image
from wsgiref.util import FileWrapper from wsgiref.util import FileWrapper
from django.http import StreamingHttpResponse from django.http import StreamingHttpResponse
from django.core.exceptions import ValidationError
from django.utils.translation import ugettext as _
def TestIfImage(img): def TestIfImage(img):
@ -115,3 +118,74 @@ def DownloadFile(data, filename, content_type='application/text'):
response['Content-Disposition'] = 'attachment; filename={f}'.format(f=filename) response['Content-Disposition'] = 'attachment; filename={f}'.format(f=filename)
return response 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

View File

@ -275,7 +275,7 @@ function loadStockTrackingTable(table, options) {
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
var m = moment(value); var m = moment(value);
if (m.isValid()) { if (m.isValid()) {
var html = m.format('dddd MMMM Do YYYY') + '<br>' + m.format('h:mm a'); var html = m.format('dddd MMMM Do YYYY'); // + '<br>' + m.format('h:mm a');
return html; return html;
} }
@ -308,6 +308,10 @@ function loadStockTrackingTable(table, options) {
html += "<br><i>" + row.notes + "</i>"; html += "<br><i>" + row.notes + "</i>";
} }
if (row.URL) {
html += "<br><a href='" + row.URL + "'>" + row.URL + "</a>";
}
return html; 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 = "<button title='Edit tracking entry' class='btn btn-entry-edit btn-default btn-glyph' type='button' url='/stock/track/" + row.pk + "/edit/'><span class='glyphicon glyphicon-edit'/></button>";
var bDel = "<button title='Delete tracking entry' class='btn btn-entry-delete btn-default btn-glyph' type='button' url='/stock/track/" + row.pk + "/delete/'><span class='glyphicon glyphicon-trash'/></button>";
return "<div class='btn-group' role='group'>" + bEdit + bDel + "</div>";
} else {
return "";
}
}
});
table.bootstrapTable({ table.bootstrapTable({
sortable: true, sortable: true,
search: true, search: true,
@ -349,4 +368,20 @@ function loadStockTrackingTable(table, options) {
if (options.buttons) { if (options.buttons) {
linkButtonsToSelection(table, 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,
});
});
} }

View File

@ -48,11 +48,14 @@ class CompleteBuildForm(HelperForm):
help_text='Location of completed parts', 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') confirm = forms.BooleanField(required=False, help_text='Confirm build submission')
class Meta: class Meta:
model = Build model = Build
fields = [ fields = [
'serial_numbers',
'location', 'location',
'confirm' 'confirm'
] ]

View File

@ -199,7 +199,7 @@ class Build(models.Model):
build_item.save() build_item.save()
@transaction.atomic @transaction.atomic
def completeBuild(self, location, user): def completeBuild(self, location, serial_numbers, user):
""" Mark the Build as COMPLETE """ Mark the Build as COMPLETE
- Takes allocated items from stock - Takes allocated items from stock
@ -227,19 +227,36 @@ class Build(models.Model):
self.completed_by = user self.completed_by = user
# Add stock of the newly created item notes = 'Built {q} on {now}'.format(
item = StockItem.objects.create( q=self.quantity,
part=self.part, now=str(datetime.now().date())
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())
)
) )
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 # Finally, mark the build as complete
self.status = BuildStatus.COMPLETE self.status = BuildStatus.COMPLETE

View File

@ -5,6 +5,8 @@ Django views for interacting with Build objects
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals 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.views.generic import DetailView, ListView
from django.forms import HiddenInput from django.forms import HiddenInput
@ -14,7 +16,7 @@ from . import forms
from stock.models import StockLocation, StockItem from stock.models import StockLocation, StockItem
from InvenTree.views import AjaxUpdateView, AjaxCreateView, AjaxDeleteView from InvenTree.views import AjaxUpdateView, AjaxCreateView, AjaxDeleteView
from InvenTree.helpers import str2bool from InvenTree.helpers import str2bool, ExtractSerialNumbers
from InvenTree.status_codes import BuildStatus from InvenTree.status_codes import BuildStatus
@ -182,6 +184,20 @@ class BuildComplete(AjaxUpdateView):
ajax_form_title = "Complete Build" ajax_form_title = "Complete Build"
ajax_template_name = "build/complete.html" 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): def get_initial(self):
""" Get initial form data for the CompleteBuild form """ Get initial form data for the CompleteBuild form
@ -206,10 +222,11 @@ class BuildComplete(AjaxUpdateView):
- Build information is required - Build information is required
""" """
build = self.get_object() build = Build.objects.get(id=self.kwargs['pk'])
context = {}
# Build object # Build object
context = super(BuildComplete, self).get_context_data(**kwargs).copy()
context['build'] = build context['build'] = build
# Items to be removed from stock # Items to be removed from stock
@ -246,14 +263,40 @@ class BuildComplete(AjaxUpdateView):
except StockLocation.DoesNotExist: except StockLocation.DoesNotExist:
form.errors['location'] = ['Invalid location selected'] 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: if valid:
build.completeBuild(location, request.user) build.completeBuild(location, serials, request.user)
data = { data = {
'form_valid': valid, 'form_valid': valid,
} }
return self.renderJsonResponse(request, form, data) return self.renderJsonResponse(request, form, data, context=self.get_context_data())
def get_data(self): def get_data(self):
""" Provide feedback data back to the form """ """ Provide feedback data back to the form """

View File

@ -116,9 +116,9 @@
<td><b>Trackable</b></td> <td><b>Trackable</b></td>
<td>{% include "slide.html" with state=part.trackable field='trackable' %}</td> <td>{% include "slide.html" with state=part.trackable field='trackable' %}</td>
{% if part.trackable %} {% if part.trackable %}
<td>Part stock will be tracked by (serial or batch)</td> <td>Part stock is tracked by serial number</td>
{% else %} {% else %}
<td><i>Part stock will not be tracked by</i></td> <td><i>Part stock is not tracked by serial number</i></td>
{% endif %} {% endif %}
</tr> </tr>
<tr> <tr>

View File

@ -16,12 +16,12 @@
</p> </p>
{% endif %} {% endif %}
{% if part.locations.all|length > 0 %} {% if part.stock_items.all|length > 0 %}
<hr> <hr>
<p>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: <p>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:
<ul class='list-group'> <ul class='list-group'>
{% for stock in part.locations.all %} {% for stock in part.stock_items.all %}
<li class='list-group-item'>{{ stock.location.name }} - {{ stock.quantity }} items</li> <li class='list-group-item'>{{ stock }}</li>
{% endfor %} {% endfor %}
</ul> </ul>
</p> </p>

View File

@ -6,9 +6,10 @@ Django Forms for interacting with Stock app
from __future__ import unicode_literals from __future__ import unicode_literals
from django import forms 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): class EditStockLocationForm(HelperForm):
@ -26,6 +27,8 @@ class EditStockLocationForm(HelperForm):
class CreateStockItemForm(HelperForm): class CreateStockItemForm(HelperForm):
""" Form for creating a new StockItem """ """ Form for creating a new StockItem """
serial_numbers = forms.CharField(label='Serial numbers', required=False, help_text='Enter unique serial numbers')
class Meta: class Meta:
model = StockItem model = StockItem
fields = [ fields = [
@ -34,13 +37,30 @@ class CreateStockItemForm(HelperForm):
'location', 'location',
'quantity', 'quantity',
'batch', 'batch',
'serial', 'serial_numbers',
'delete_on_deplete', 'delete_on_deplete',
'status', 'status',
'notes', 'notes',
'URL', '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): class AdjustStockForm(forms.ModelForm):
""" Form for performing simple stock adjustments. """ Form for performing simple stock adjustments.
@ -104,3 +124,17 @@ class EditStockItemForm(HelperForm):
'notes', 'notes',
'URL', 'URL',
] ]
class TrackingEntryForm(HelperForm):
""" Form for creating / editing a StockItemTracking object.
"""
class Meta:
model = StockItemTracking
fields = [
'title',
'notes',
'URL',
]

View File

@ -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'),
),
]

View File

@ -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),
),
]

View File

@ -92,6 +92,7 @@ class StockItem(models.Model):
location: Where this StockItem is located location: Where this StockItem is located
quantity: Number of stocked units quantity: Number of stocked units
batch: Batch number for this StockItem batch: Batch number for this StockItem
serial: Unique serial number for this StockItem
URL: Optional URL to link to external resource URL: Optional URL to link to external resource
updated: Date that this stock item was last updated (auto) updated: Date that this stock item was last updated (auto)
stocktake_date: Date of last stocktake for this item stocktake_date: Date of last stocktake for this item
@ -121,6 +122,31 @@ class StockItem(models.Model):
system=True 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): def validate_unique(self, exclude=None):
super(StockItem, self).validate_unique(exclude) super(StockItem, self).validate_unique(exclude)
@ -129,11 +155,18 @@ class StockItem(models.Model):
# across all variants of the same template part # across all variants of the same template part
try: try:
if self.serial is not None and self.part.variant_of is not None: if self.serial is not None:
if StockItem.objects.filter(part__variant_of=self.part.variant_of, serial=self.serial).exclude(id=self.id).exists(): # This is a variant part (check S/N across all sibling variants)
raise ValidationError({ if self.part.variant_of is not None:
'serial': _('A part with this serial number already exists for template part {part}'.format(part=self.part.variant_of)) 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: except Part.DoesNotExist:
pass pass
@ -158,16 +191,23 @@ class StockItem(models.Model):
if self.part is not None: if self.part is not None:
# A trackable part must have a serial number # A trackable part must have a serial number
if self.part.trackable and not self.serial: if self.part.trackable:
raise ValidationError({ if not self.serial:
'serial': _('Serial number must be set for trackable items') 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 # A template part cannot be instantiated as a StockItem
if self.part.is_template: if self.part.is_template:
raise ValidationError({ raise ValidationError({'part': _('Stock item cannot be created for a template Part')})
'part': _('Stock item cannot be created for a template Part')
})
except Part.DoesNotExist: except Part.DoesNotExist:
# This gets thrown if self.supplier_part is null # This gets thrown if self.supplier_part is null
@ -179,13 +219,6 @@ class StockItem(models.Model):
'belongs_to': _('Item cannot belong to itself') '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): def get_absolute_url(self):
return reverse('stock-item-detail', kwargs={'pk': self.id}) return reverse('stock-item-detail', kwargs={'pk': self.id})
@ -298,7 +331,7 @@ class StockItem(models.Model):
def has_tracking_info(self): def has_tracking_info(self):
return self.tracking_info.count() > 0 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. """ Generation a stock transaction note for this item.
Brief automated note detailing a movement or quantity change. Brief automated note detailing a movement or quantity change.
@ -310,6 +343,7 @@ class StockItem(models.Model):
quantity=self.quantity, quantity=self.quantity,
date=datetime.now().date(), date=datetime.now().date(),
notes=notes, notes=notes,
URL=url,
system=system system=system
) )
@ -494,9 +528,14 @@ class StockItem(models.Model):
return True return True
def __str__(self): def __str__(self):
s = '{n} x {part}'.format( if self.part.trackable and self.serial:
n=self.quantity, s = '{part} #{sn}'.format(
part=self.part.full_name) 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: if self.location:
s += ' @ {loc}'.format(loc=self.location.name) s += ' @ {loc}'.format(loc=self.location.name)
@ -512,6 +551,7 @@ class StockItemTracking(models.Model):
date: Date that this tracking info was created date: Date that this tracking info was created
title: Title of this tracking info (generated by system) title: Title of this tracking info (generated by system)
notes: Associated notes (input by user) notes: Associated notes (input by user)
URL: Optional URL to external page
user: The user associated with this tracking info user: The user associated with this tracking info
quantity: The StockItem quantity at this point in time 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) 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) user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True)

View File

@ -149,6 +149,7 @@ class StockTrackingSerializer(InvenTreeModelSerializer):
'date', 'date',
'title', 'title',
'notes', 'notes',
'URL',
'quantity', 'quantity',
'user', 'user',
'system', 'system',

View File

@ -125,19 +125,29 @@
</div> </div>
</div> </div>
{% if item.has_tracking_info %}
<hr> <hr>
<div id='table-toolbar'> <h4>Stock Tracking Information</h4>
<h4>Stock Tracking Information</h4> <div id='table-toolbar'>
<div class='btn-group'>
<button class='btn btn-success' type='button' title='New tracking entry' id='new-entry'>New Entry</button>
</div>
</div> </div>
<table class='table table-condensed table-striped' id='track-table' data-toolbar='#table-toolbar'> <table class='table table-condensed table-striped' id='track-table' data-toolbar='#table-toolbar'>
</table> </table>
{% endif %}
{% endblock %} {% endblock %}
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
$("#new-entry").click(function() {
launchModalForm(
"{% url 'stock-tracking-create' item.id %}",
{
reload: true,
}
);
});
$("#stock-duplicate").click(function() { $("#stock-duplicate").click(function() {
launchModalForm( launchModalForm(
"{% url 'stock-item-create' %}", "{% url 'stock-item-create' %}",
@ -152,11 +162,12 @@
$("#stock-edit").click(function () { $("#stock-edit").click(function () {
launchModalForm( launchModalForm(
"{% url 'stock-item-edit' item.id %}", "{% url 'stock-item-edit' item.id %}",
{ {
reload: true, reload: true,
submit_text: "Save", submit_text: "Save",
}); }
);
}); });
$("#show-qr-code").click(function() { $("#show-qr-code").click(function() {

View File

@ -35,7 +35,6 @@ InvenTree | Stock
"#stock-tree", "#stock-tree",
{ {
name: 'stock', name: 'stock',
selected: 'elab',
} }
); );

View File

@ -0,0 +1,9 @@
{% extends "modal_delete_form.html" %}
{% block pre_form_content %}
<div class='alert alert-danger alert-block'>
Are you sure you want to delete this stock tracking entry?
</div>
{% endblock %}

View File

@ -21,9 +21,23 @@ stock_item_detail_urls = [
url(r'^delete/?', views.StockItemDelete.as_view(), name='stock-item-delete'), 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'^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'), url('^.*$', views.StockItemDetail.as_view(), name='stock-item-detail'),
] ]
stock_tracking_urls = [
# edit
url(r'^(?P<pk>\d+)/edit/', views.StockItemTrackingEdit.as_view(), name='stock-tracking-edit'),
# delete
url(r'^(?P<pk>\d+)/delete', views.StockItemTrackingDelete.as_view(), name='stock-tracking-delete'),
# list
url('^.*$', views.StockTrackingIndex.as_view(), name='stock-tracking-list')
]
stock_urls = [ stock_urls = [
# Stock location # Stock location
url(r'^location/(?P<pk>\d+)/', include(stock_location_detail_urls)), url(r'^location/(?P<pk>\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'^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'), url(r'^adjust/?', views.StockAdjust.as_view(), name='stock-adjust'),

View File

@ -5,6 +5,7 @@ Django views for interacting with Stock app
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from django.core.exceptions import ValidationError
from django.views.generic.edit import FormMixin from django.views.generic.edit import FormMixin
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from django.forms.models import model_to_dict 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.views import QRCodeView
from InvenTree.helpers import str2bool from InvenTree.helpers import str2bool
from InvenTree.helpers import ExtractSerialNumbers
from datetime import datetime
from part.models import Part from part.models import Part
from .models import StockItem, StockLocation, StockItemTracking from .models import StockItem, StockLocation, StockItemTracking
@ -25,6 +28,7 @@ from .forms import EditStockLocationForm
from .forms import CreateStockItemForm from .forms import CreateStockItemForm
from .forms import EditStockItemForm from .forms import EditStockItemForm
from .forms import AdjustStockForm from .forms import AdjustStockForm
from .forms import TrackingEntryForm
class StockIndex(ListView): class StockIndex(ListView):
@ -474,7 +478,7 @@ class StockItemCreate(AjaxCreateView):
ForeignKey choices based on other selections 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 the user has selected a Part, limit choices for SupplierPart
if form['part'].value(): if form['part'].value():
@ -486,10 +490,16 @@ class StockItemCreate(AjaxCreateView):
# Hide the 'part' field (as a valid part is selected) # Hide the 'part' field (as a valid part is selected)
form.fields['part'].widget = HiddenInput() 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 the part is NOT purchaseable, hide the supplier_part field
if not part.purchaseable: if not part.purchaseable:
form.fields['supplier_part'].widget = HiddenInput() form.fields['supplier_part'].widget = HiddenInput()
else: else:
# Pre-select the allowable SupplierPart options # Pre-select the allowable SupplierPart options
parts = form.fields['supplier_part'].queryset parts = form.fields['supplier_part'].queryset
@ -553,6 +563,87 @@ class StockItemCreate(AjaxCreateView):
return initials 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): class StockLocationDelete(AjaxDeleteView):
""" """
@ -580,6 +671,17 @@ class StockItemDelete(AjaxDeleteView):
ajax_form_title = 'Delete Stock Item' 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): class StockTrackingIndex(ListView):
""" """
StockTrackingIndex provides a page to display StockItemTracking objects StockTrackingIndex provides a page to display StockItemTracking objects
@ -588,3 +690,55 @@ class StockTrackingIndex(ListView):
model = StockItemTracking model = StockItemTracking
template_name = 'stock/tracking.html' template_name = 'stock/tracking.html'
context_object_name = 'items' 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)