mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
commit
163f076565
@ -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
|
||||
|
@ -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') + '<br>' + m.format('h:mm a');
|
||||
var html = m.format('dddd MMMM Do YYYY'); // + '<br>' + m.format('h:mm a');
|
||||
return html;
|
||||
}
|
||||
|
||||
@ -308,6 +308,10 @@ function loadStockTrackingTable(table, options) {
|
||||
html += "<br><i>" + row.notes + "</i>";
|
||||
}
|
||||
|
||||
if (row.URL) {
|
||||
html += "<br><a href='" + row.URL + "'>" + row.URL + "</a>";
|
||||
}
|
||||
|
||||
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({
|
||||
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,
|
||||
});
|
||||
});
|
||||
}
|
@ -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'
|
||||
]
|
||||
|
@ -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
|
||||
|
@ -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 """
|
||||
|
@ -116,9 +116,9 @@
|
||||
<td><b>Trackable</b></td>
|
||||
<td>{% include "slide.html" with state=part.trackable field='trackable' %}</td>
|
||||
{% if part.trackable %}
|
||||
<td>Part stock will be tracked by (serial or batch)</td>
|
||||
<td>Part stock is tracked by serial number</td>
|
||||
{% 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 %}
|
||||
</tr>
|
||||
<tr>
|
||||
|
@ -16,12 +16,12 @@
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if part.locations.all|length > 0 %}
|
||||
{% if part.stock_items.all|length > 0 %}
|
||||
<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'>
|
||||
{% for stock in part.locations.all %}
|
||||
<li class='list-group-item'>{{ stock.location.name }} - {{ stock.quantity }} items</li>
|
||||
{% for stock in part.stock_items.all %}
|
||||
<li class='list-group-item'>{{ stock }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</p>
|
||||
|
@ -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',
|
||||
]
|
||||
|
18
InvenTree/stock/migrations/0008_stockitemtracking_url.py
Normal file
18
InvenTree/stock/migrations/0008_stockitemtracking_url.py
Normal 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'),
|
||||
),
|
||||
]
|
23
InvenTree/stock/migrations/0009_auto_20190715_2351.py
Normal file
23
InvenTree/stock/migrations/0009_auto_20190715_2351.py
Normal 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),
|
||||
),
|
||||
]
|
@ -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)
|
||||
|
||||
|
@ -149,6 +149,7 @@ class StockTrackingSerializer(InvenTreeModelSerializer):
|
||||
'date',
|
||||
'title',
|
||||
'notes',
|
||||
'URL',
|
||||
'quantity',
|
||||
'user',
|
||||
'system',
|
||||
|
@ -125,19 +125,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% if item.has_tracking_info %}
|
||||
<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>
|
||||
<table class='table table-condensed table-striped' id='track-table' data-toolbar='#table-toolbar'>
|
||||
</table>
|
||||
{% 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() {
|
||||
|
@ -35,7 +35,6 @@ InvenTree | Stock
|
||||
"#stock-tree",
|
||||
{
|
||||
name: 'stock',
|
||||
selected: 'elab',
|
||||
}
|
||||
);
|
||||
|
||||
|
9
InvenTree/stock/templates/stock/tracking_delete.html
Normal file
9
InvenTree/stock/templates/stock/tracking_delete.html
Normal 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 %}
|
@ -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<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 location
|
||||
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'^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'),
|
||||
|
||||
|
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user