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 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
|
||||||
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
@ -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'
|
||||||
]
|
]
|
||||||
|
@ -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
|
||||||
|
@ -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 """
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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',
|
||||||
|
]
|
||||||
|
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
|
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)
|
||||||
|
|
||||||
|
@ -149,6 +149,7 @@ class StockTrackingSerializer(InvenTreeModelSerializer):
|
|||||||
'date',
|
'date',
|
||||||
'title',
|
'title',
|
||||||
'notes',
|
'notes',
|
||||||
|
'URL',
|
||||||
'quantity',
|
'quantity',
|
||||||
'user',
|
'user',
|
||||||
'system',
|
'system',
|
||||||
|
@ -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() {
|
||||||
|
@ -35,7 +35,6 @@ InvenTree | Stock
|
|||||||
"#stock-tree",
|
"#stock-tree",
|
||||||
{
|
{
|
||||||
name: 'stock',
|
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'^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'),
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
Loading…
Reference in New Issue
Block a user