mirror of
https://github.com/inventree/InvenTree.git
synced 2025-07-01 03:00:54 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
@ -80,21 +80,10 @@ class StockDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
|
||||
try:
|
||||
kwargs['part_detail'] = str2bool(self.request.query_params.get('part_detail', False))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
try:
|
||||
kwargs['location_detail'] = str2bool(self.request.query_params.get('location_detail', False))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
try:
|
||||
kwargs['supplier_part_detail'] = str2bool(self.request.query_params.get('supplier_detail', False))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
kwargs['part_detail'] = True
|
||||
kwargs['location_detail'] = True
|
||||
kwargs['supplier_part_detail'] = True
|
||||
kwargs['test_detail'] = True
|
||||
kwargs['context'] = self.get_serializer_context()
|
||||
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
@ -498,8 +487,21 @@ class StockList(generics.ListCreateAPIView):
|
||||
|
||||
if serial_number is not None:
|
||||
queryset = queryset.filter(serial=serial_number)
|
||||
|
||||
# Filter by range of serial numbers?
|
||||
serial_number_gte = params.get('serial_gte', None)
|
||||
serial_number_lte = params.get('serial_lte', None)
|
||||
|
||||
if serial_number_gte is not None or serial_number_lte is not None:
|
||||
queryset = queryset.exclude(serial=None)
|
||||
|
||||
if serial_number_gte is not None:
|
||||
queryset = queryset.filter(serial__gte=serial_number_gte)
|
||||
|
||||
in_stock = self.request.query_params.get('in_stock', None)
|
||||
if serial_number_lte is not None:
|
||||
queryset = queryset.filter(serial__lte=serial_number_lte)
|
||||
|
||||
in_stock = params.get('in_stock', None)
|
||||
|
||||
if in_stock is not None:
|
||||
in_stock = str2bool(in_stock)
|
||||
@ -512,7 +514,7 @@ class StockList(generics.ListCreateAPIView):
|
||||
queryset = queryset.exclude(StockItem.IN_STOCK_FILTER)
|
||||
|
||||
# Filter by 'allocated' patrs?
|
||||
allocated = self.request.query_params.get('allocated', None)
|
||||
allocated = params.get('allocated', None)
|
||||
|
||||
if allocated is not None:
|
||||
allocated = str2bool(allocated)
|
||||
@ -531,8 +533,14 @@ class StockList(generics.ListCreateAPIView):
|
||||
active = str2bool(active)
|
||||
queryset = queryset.filter(part__active=active)
|
||||
|
||||
# Filter by internal part number
|
||||
IPN = params.get('IPN', None)
|
||||
|
||||
if IPN:
|
||||
queryset = queryset.filter(part__IPN=IPN)
|
||||
|
||||
# Does the client wish to filter by the Part ID?
|
||||
part_id = self.request.query_params.get('part', None)
|
||||
part_id = params.get('part', None)
|
||||
|
||||
if part_id:
|
||||
try:
|
||||
@ -692,17 +700,14 @@ class StockItemTestResultList(generics.ListCreateAPIView):
|
||||
'value',
|
||||
]
|
||||
|
||||
ordering = 'date'
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
try:
|
||||
kwargs['user_detail'] = str2bool(self.request.query_params.get('user_detail', False))
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
kwargs['attachment_detail'] = str2bool(self.request.query_params.get('attachment_detail', False))
|
||||
except:
|
||||
pass
|
||||
|
||||
kwargs['context'] = self.get_serializer_context()
|
||||
|
||||
return self.serializer_class(*args, **kwargs)
|
||||
@ -718,23 +723,6 @@ class StockItemTestResultList(generics.ListCreateAPIView):
|
||||
# Capture the user information
|
||||
test_result = serializer.save()
|
||||
test_result.user = self.request.user
|
||||
|
||||
# Check if a file has been attached to the request
|
||||
attachment_file = self.request.FILES.get('attachment', None)
|
||||
|
||||
if attachment_file:
|
||||
# Create a new attachment associated with the stock item
|
||||
attachment = StockItemAttachment(
|
||||
attachment=attachment_file,
|
||||
stock_item=test_result.stock_item,
|
||||
user=test_result.user
|
||||
)
|
||||
|
||||
attachment.save()
|
||||
|
||||
# Link the attachment back to the test result
|
||||
test_result.attachment = attachment
|
||||
|
||||
test_result.save()
|
||||
|
||||
|
||||
|
@ -63,6 +63,18 @@ class EditStockLocationForm(HelperForm):
|
||||
]
|
||||
|
||||
|
||||
class ConvertStockItemForm(HelperForm):
|
||||
"""
|
||||
Form for converting a StockItem to a variant of its current part.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = StockItem
|
||||
fields = [
|
||||
'part'
|
||||
]
|
||||
|
||||
|
||||
class CreateStockItemForm(HelperForm):
|
||||
""" Form for creating a new StockItem """
|
||||
|
||||
@ -142,6 +154,34 @@ class SerializeStockForm(HelperForm):
|
||||
]
|
||||
|
||||
|
||||
class TestReportFormatForm(HelperForm):
|
||||
""" Form for selection a test report template """
|
||||
|
||||
class Meta:
|
||||
model = StockItem
|
||||
fields = [
|
||||
'template',
|
||||
]
|
||||
|
||||
def __init__(self, stock_item, *args, **kwargs):
|
||||
self.stock_item = stock_item
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['template'].choices = self.get_template_choices()
|
||||
|
||||
def get_template_choices(self):
|
||||
""" Available choices """
|
||||
|
||||
choices = []
|
||||
|
||||
for report in self.stock_item.part.get_test_report_templates():
|
||||
choices.append((report.pk, report))
|
||||
|
||||
return choices
|
||||
|
||||
template = forms.ChoiceField(label=_('Template'), help_text=_('Select test report template'))
|
||||
|
||||
|
||||
class ExportOptionsForm(HelperForm):
|
||||
""" Form for selecting stock export options """
|
||||
|
||||
|
19
InvenTree/stock/migrations/0042_auto_20200523_0121.py
Normal file
19
InvenTree/stock/migrations/0042_auto_20200523_0121.py
Normal file
@ -0,0 +1,19 @@
|
||||
# Generated by Django 3.0.5 on 2020-05-23 01:21
|
||||
|
||||
from django.db import migrations, models
|
||||
import stock.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('stock', '0041_stockitemtestresult_notes'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='stockitemtestresult',
|
||||
name='attachment',
|
||||
field=models.FileField(blank=True, help_text='Test result attachment', null=True, upload_to=stock.models.rename_stock_item_test_result_attachment, verbose_name='Attachment'),
|
||||
),
|
||||
]
|
20
InvenTree/stock/migrations/0043_auto_20200525_0420.py
Normal file
20
InvenTree/stock/migrations/0043_auto_20200525_0420.py
Normal file
@ -0,0 +1,20 @@
|
||||
# Generated by Django 3.0.5 on 2020-05-25 04:20
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('part', '0042_auto_20200518_0900'),
|
||||
('stock', '0042_auto_20200523_0121'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='stockitem',
|
||||
name='part',
|
||||
field=models.ForeignKey(help_text='Base part', limit_choices_to={'active': True, 'virtual': False}, on_delete=django.db.models.deletion.CASCADE, related_name='stock_items', to='part.Part', verbose_name='Base Part'),
|
||||
),
|
||||
]
|
@ -331,7 +331,6 @@ class StockItem(MPTTModel):
|
||||
verbose_name=_('Base Part'),
|
||||
related_name='stock_items', help_text=_('Base part'),
|
||||
limit_choices_to={
|
||||
'is_template': False,
|
||||
'active': True,
|
||||
'virtual': False
|
||||
})
|
||||
@ -647,6 +646,9 @@ class StockItem(MPTTModel):
|
||||
# Copy entire transaction history
|
||||
new_item.copyHistoryFrom(self)
|
||||
|
||||
# Copy test result history
|
||||
new_item.copyTestResultsFrom(self)
|
||||
|
||||
# Create a new stock tracking item
|
||||
new_item.addTransactionNote(_('Add serial number'), user, notes=notes)
|
||||
|
||||
@ -655,7 +657,7 @@ class StockItem(MPTTModel):
|
||||
|
||||
@transaction.atomic
|
||||
def copyHistoryFrom(self, other):
|
||||
""" Copy stock history from another part """
|
||||
""" Copy stock history from another StockItem """
|
||||
|
||||
for item in other.tracking_info.all():
|
||||
|
||||
@ -663,6 +665,17 @@ class StockItem(MPTTModel):
|
||||
item.pk = None
|
||||
item.save()
|
||||
|
||||
@transaction.atomic
|
||||
def copyTestResultsFrom(self, other, filters={}):
|
||||
""" Copy all test results from another StockItem """
|
||||
|
||||
for result in other.test_results.all().filter(**filters):
|
||||
|
||||
# Create a copy of the test result by nulling-out the pk
|
||||
result.pk = None
|
||||
result.stock_item = self
|
||||
result.save()
|
||||
|
||||
@transaction.atomic
|
||||
def splitStock(self, quantity, location, user):
|
||||
""" Split this stock item into two items, in the same location.
|
||||
@ -713,6 +726,9 @@ class StockItem(MPTTModel):
|
||||
# Copy the transaction history of this part into the new one
|
||||
new_stock.copyHistoryFrom(self)
|
||||
|
||||
# Copy the test results of this part to the new one
|
||||
new_stock.copyTestResultsFrom(self)
|
||||
|
||||
# Add a new tracking item for the new stock item
|
||||
new_stock.addTransactionNote(
|
||||
"Split from existing stock",
|
||||
@ -963,6 +979,13 @@ class StockItem(MPTTModel):
|
||||
|
||||
return result_map
|
||||
|
||||
def testResultList(self, **kwargs):
|
||||
"""
|
||||
Return a list of test-result objects for this StockItem
|
||||
"""
|
||||
|
||||
return self.testResultMap(**kwargs).values()
|
||||
|
||||
def requiredTestStatus(self):
|
||||
"""
|
||||
Return the status of the tests required for this StockItem.
|
||||
@ -1000,6 +1023,10 @@ class StockItem(MPTTModel):
|
||||
'failed': failed,
|
||||
}
|
||||
|
||||
@property
|
||||
def required_test_count(self):
|
||||
return self.part.getRequiredTests().count()
|
||||
|
||||
def hasRequiredTests(self):
|
||||
return self.part.getRequiredTests().count() > 0
|
||||
|
||||
@ -1083,6 +1110,11 @@ class StockItemTracking(models.Model):
|
||||
# file = models.FileField()
|
||||
|
||||
|
||||
def rename_stock_item_test_result_attachment(instance, filename):
|
||||
|
||||
return os.path.join('stock_files', str(instance.stock_item.pk), os.path.basename(filename))
|
||||
|
||||
|
||||
class StockItemTestResult(models.Model):
|
||||
"""
|
||||
A StockItemTestResult records results of custom tests against individual StockItem objects.
|
||||
@ -1102,19 +1134,41 @@ class StockItemTestResult(models.Model):
|
||||
date: Date the test result was recorded
|
||||
"""
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
super().clean()
|
||||
super().validate_unique()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
|
||||
super().clean()
|
||||
|
||||
# If an attachment is linked to this result, the attachment must also point to the item
|
||||
try:
|
||||
if self.attachment:
|
||||
if not self.attachment.stock_item == self.stock_item:
|
||||
raise ValidationError({
|
||||
'attachment': _("Test result attachment must be linked to the same StockItem"),
|
||||
})
|
||||
except (StockItem.DoesNotExist, StockItemAttachment.DoesNotExist):
|
||||
pass
|
||||
# If this test result corresponds to a template, check the requirements of the template
|
||||
key = self.key
|
||||
|
||||
templates = self.stock_item.part.getTestTemplates()
|
||||
|
||||
for template in templates:
|
||||
if key == template.key:
|
||||
|
||||
if template.requires_value:
|
||||
if not self.value:
|
||||
raise ValidationError({
|
||||
"value": _("Value must be provided for this test"),
|
||||
})
|
||||
|
||||
if template.requires_attachment:
|
||||
if not self.attachment:
|
||||
raise ValidationError({
|
||||
"attachment": _("Attachment must be uploaded for this test"),
|
||||
})
|
||||
|
||||
break
|
||||
|
||||
@property
|
||||
def key(self):
|
||||
return helpers.generateTestKey(self.test)
|
||||
|
||||
stock_item = models.ForeignKey(
|
||||
StockItem,
|
||||
@ -1140,10 +1194,9 @@ class StockItemTestResult(models.Model):
|
||||
help_text=_('Test output value')
|
||||
)
|
||||
|
||||
attachment = models.ForeignKey(
|
||||
StockItemAttachment,
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True, null=True,
|
||||
attachment = models.FileField(
|
||||
null=True, blank=True,
|
||||
upload_to=rename_stock_item_test_result_attachment,
|
||||
verbose_name=_('Attachment'),
|
||||
help_text=_('Test result attachment'),
|
||||
)
|
||||
|
@ -108,11 +108,14 @@ class StockItemSerializer(InvenTreeModelSerializer):
|
||||
quantity = serializers.FloatField()
|
||||
allocated = serializers.FloatField()
|
||||
|
||||
required_tests = serializers.IntegerField(source='required_test_count', read_only=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
part_detail = kwargs.pop('part_detail', False)
|
||||
location_detail = kwargs.pop('location_detail', False)
|
||||
supplier_part_detail = kwargs.pop('supplier_part_detail', False)
|
||||
test_detail = kwargs.pop('test_detail', False)
|
||||
|
||||
super(StockItemSerializer, self).__init__(*args, **kwargs)
|
||||
|
||||
@ -125,6 +128,9 @@ class StockItemSerializer(InvenTreeModelSerializer):
|
||||
if supplier_part_detail is not True:
|
||||
self.fields.pop('supplier_part_detail')
|
||||
|
||||
if test_detail is not True:
|
||||
self.fields.pop('required_tests')
|
||||
|
||||
class Meta:
|
||||
model = StockItem
|
||||
fields = [
|
||||
@ -141,6 +147,7 @@ class StockItemSerializer(InvenTreeModelSerializer):
|
||||
'part_detail',
|
||||
'pk',
|
||||
'quantity',
|
||||
'required_tests',
|
||||
'sales_order',
|
||||
'serial',
|
||||
'supplier_part',
|
||||
@ -222,31 +229,28 @@ class StockItemTestResultSerializer(InvenTreeModelSerializer):
|
||||
""" Serializer for the StockItemTestResult model """
|
||||
|
||||
user_detail = UserSerializerBrief(source='user', read_only=True)
|
||||
attachment_detail = StockItemAttachmentSerializer(source='attachment', read_only=True)
|
||||
|
||||
key = serializers.CharField(read_only=True)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
user_detail = kwargs.pop('user_detail', False)
|
||||
attachment_detail = kwargs.pop('attachment_detail', False)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if user_detail is not True:
|
||||
self.fields.pop('user_detail')
|
||||
|
||||
if attachment_detail is not True:
|
||||
self.fields.pop('attachment_detail')
|
||||
|
||||
class Meta:
|
||||
model = StockItemTestResult
|
||||
|
||||
fields = [
|
||||
'pk',
|
||||
'stock_item',
|
||||
'key',
|
||||
'test',
|
||||
'result',
|
||||
'value',
|
||||
'attachment',
|
||||
'attachment_detail',
|
||||
'notes',
|
||||
'user',
|
||||
'user_detail',
|
||||
@ -255,7 +259,6 @@ class StockItemTestResultSerializer(InvenTreeModelSerializer):
|
||||
|
||||
read_only_fields = [
|
||||
'pk',
|
||||
'attachment',
|
||||
'user',
|
||||
'date',
|
||||
]
|
||||
|
@ -93,8 +93,18 @@ InvenTree | {% trans "Stock Item" %} - {{ item }}
|
||||
<span class='fas fa-copy'/>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if item.part.has_variants %}
|
||||
<button type='button' class='btn btn-default' id='stock-convert' title="Convert stock to variant">
|
||||
<span class='fas fa-screwdriver'/>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if item.part.has_test_report_templates %}
|
||||
<button type='button' class='btn btn-default' id='stock-test-report' title='Generate test report'>
|
||||
<span class='fas fa-tasks'/>
|
||||
</button>
|
||||
{% endif %}
|
||||
<button type='button' class='btn btn-default' id='stock-edit' title='Edit stock item'>
|
||||
<span class='fas fa-edit'/>
|
||||
<span class='fas fa-edit icon-blue'/>
|
||||
</button>
|
||||
{% if item.can_delete %}
|
||||
<button type='button' class='btn btn-default' id='stock-delete' title='Edit stock item'>
|
||||
@ -264,6 +274,17 @@ $("#stock-serialize").click(function() {
|
||||
);
|
||||
});
|
||||
|
||||
{% if item.part.has_test_report_templates %}
|
||||
$("#stock-test-report").click(function() {
|
||||
launchModalForm(
|
||||
"{% url 'stock-item-test-report-select' item.id %}",
|
||||
{
|
||||
follow: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
$("#stock-duplicate").click(function() {
|
||||
launchModalForm(
|
||||
"{% url 'stock-item-create' %}",
|
||||
@ -308,6 +329,16 @@ function itemAdjust(action) {
|
||||
);
|
||||
}
|
||||
|
||||
{% if item.part.has_variants %}
|
||||
$("#stock-convert").click(function() {
|
||||
launchModalForm("{% url 'stock-item-convert' item.id %}",
|
||||
{
|
||||
reload: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
$("#stock-move").click(function() {
|
||||
itemAdjust("move");
|
||||
});
|
||||
|
@ -13,7 +13,13 @@
|
||||
<div id='button-toolbar'>
|
||||
<div class='button-toolbar container-fluid' style="float: right;">
|
||||
<div class='btn-group' role='group'>
|
||||
{% if user.is_staff %}
|
||||
<button type='button' class='btn btn-danger' id='delete-test-results'>{% trans "Delete Test Data" %}</button>
|
||||
{% endif %}
|
||||
<button type='button' class='btn btn-success' id='add-test-result'>{% trans "Add Test Data" %}</button>
|
||||
{% if item.part.has_test_report_templates %}
|
||||
<button type='button' class='btn btn-default' id='test-report'>{% trans "Test Report" %} <span class='fas fa-tasks'></span></button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class='filter-list' id='filter-list-stocktests'>
|
||||
<!-- Empty div -->
|
||||
@ -40,6 +46,28 @@ function reloadTable() {
|
||||
//$("#test-result-table").bootstrapTable("refresh");
|
||||
}
|
||||
|
||||
{% if item.part.has_test_report_templates %}
|
||||
$("#test-report").click(function() {
|
||||
launchModalForm(
|
||||
"{% url 'stock-item-test-report-select' item.id %}",
|
||||
{
|
||||
follow: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
{% if user.is_staff %}
|
||||
$("#delete-test-results").click(function() {
|
||||
launchModalForm(
|
||||
"{% url 'stock-item-delete-test-data' item.id %}",
|
||||
{
|
||||
success: reloadTable,
|
||||
}
|
||||
);
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
$("#add-test-result").click(function() {
|
||||
launchModalForm(
|
||||
"{% url 'stock-item-test-create' %}", {
|
||||
|
@ -32,7 +32,7 @@
|
||||
<input class='numberinput'
|
||||
min='0'
|
||||
{% if stock_action == 'take' or stock_action == 'move' %} max='{{ item.quantity }}' {% endif %}
|
||||
value='{{ item.new_quantity }}' type='number' name='stock-id-{{ item.id }}' id='stock-id-{{ item.id }}'/>
|
||||
value='{% decimal item.new_quantity %}' type='number' name='stock-id-{{ item.id }}' id='stock-id-{{ item.id }}'/>
|
||||
{% if item.error %}
|
||||
<br><span class='help-inline'>{{ item.error }}</span>
|
||||
{% endif %}
|
||||
|
17
InvenTree/stock/templates/stock/stockitem_convert.html
Normal file
17
InvenTree/stock/templates/stock/stockitem_convert.html
Normal file
@ -0,0 +1,17 @@
|
||||
{% extends "modal_form.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
|
||||
<div class='alert alert-block alert-info'>
|
||||
<b>{% trans "Convert Stock Item" %}</b><br>
|
||||
{% trans "This stock item is current an instance of " %}<i>{{ item.part }}</i><br>
|
||||
{% trans "It can be converted to one of the part variants listed below." %}
|
||||
</div>
|
||||
|
||||
<div class='alert alert-block alert-warning'>
|
||||
<b>{% trans "Warning" %}</b>
|
||||
{% trans "This action cannot be easily undone" %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
@ -458,3 +458,68 @@ class TestResultTest(StockTest):
|
||||
)
|
||||
|
||||
self.assertTrue(item.passedAllRequiredTests())
|
||||
|
||||
def test_duplicate_item_tests(self):
|
||||
|
||||
# Create an example stock item by copying one from the database (because we are lazy)
|
||||
item = StockItem.objects.get(pk=522)
|
||||
|
||||
item.pk = None
|
||||
item.serial = None
|
||||
item.quantity = 50
|
||||
|
||||
item.save()
|
||||
|
||||
# Do some tests!
|
||||
StockItemTestResult.objects.create(
|
||||
stock_item=item,
|
||||
test="Firmware",
|
||||
result=True
|
||||
)
|
||||
|
||||
StockItemTestResult.objects.create(
|
||||
stock_item=item,
|
||||
test="Paint Color",
|
||||
result=True,
|
||||
value="Red"
|
||||
)
|
||||
|
||||
StockItemTestResult.objects.create(
|
||||
stock_item=item,
|
||||
test="Applied Sticker",
|
||||
result=False
|
||||
)
|
||||
|
||||
self.assertEqual(item.test_results.count(), 3)
|
||||
self.assertEqual(item.quantity, 50)
|
||||
|
||||
# Split some items out
|
||||
item2 = item.splitStock(20, None, None)
|
||||
|
||||
self.assertEqual(item.quantity, 30)
|
||||
|
||||
self.assertEqual(item.test_results.count(), 3)
|
||||
self.assertEqual(item2.test_results.count(), 3)
|
||||
|
||||
StockItemTestResult.objects.create(
|
||||
stock_item=item2,
|
||||
test='A new test'
|
||||
)
|
||||
|
||||
self.assertEqual(item.test_results.count(), 3)
|
||||
self.assertEqual(item2.test_results.count(), 4)
|
||||
|
||||
# Test StockItem serialization
|
||||
item2.serializeStock(1, [100], self.user)
|
||||
|
||||
# Add a test result to the parent *after* serialization
|
||||
StockItemTestResult.objects.create(
|
||||
stock_item=item2,
|
||||
test='abcde'
|
||||
)
|
||||
|
||||
self.assertEqual(item2.test_results.count(), 5)
|
||||
|
||||
item3 = StockItem.objects.get(serial=100, part=item2.part)
|
||||
|
||||
self.assertEqual(item3.test_results.count(), 4)
|
||||
|
@ -18,12 +18,16 @@ stock_location_detail_urls = [
|
||||
|
||||
stock_item_detail_urls = [
|
||||
url(r'^edit/', views.StockItemEdit.as_view(), name='stock-item-edit'),
|
||||
url(r'^convert/', views.StockItemConvert.as_view(), name='stock-item-convert'),
|
||||
url(r'^serialize/', views.StockItemSerialize.as_view(), name='stock-item-serialize'),
|
||||
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'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'),
|
||||
|
||||
url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'),
|
||||
|
||||
url(r'^test-report-select/', views.StockItemTestReportSelect.as_view(), name='stock-item-test-report-select'),
|
||||
|
||||
url(r'^test/', views.StockItemDetail.as_view(template_name='stock/item_tests.html'), name='stock-item-test-results'),
|
||||
url(r'^children/', views.StockItemDetail.as_view(template_name='stock/item_childs.html'), name='stock-item-children'),
|
||||
url(r'^attachments/', views.StockItemDetail.as_view(template_name='stock/item_attachments.html'), name='stock-item-attachments'),
|
||||
@ -52,6 +56,8 @@ stock_urls = [
|
||||
|
||||
url(r'^item/new/?', views.StockItemCreate.as_view(), name='stock-item-create'),
|
||||
|
||||
url(r'^item/test-report-download/', views.StockItemTestReportDownload.as_view(), name='stock-item-test-report-download'),
|
||||
|
||||
# URLs for StockItem attachments
|
||||
url(r'^item/attachment/', include([
|
||||
url(r'^new/', views.StockItemAttachmentCreate.as_view(), name='stock-item-attachment-create'),
|
||||
|
@ -17,6 +17,7 @@ from django.utils.translation import ugettext as _
|
||||
from InvenTree.views import AjaxView
|
||||
from InvenTree.views import AjaxUpdateView, AjaxDeleteView, AjaxCreateView
|
||||
from InvenTree.views import QRCodeView
|
||||
from InvenTree.forms import ConfirmForm
|
||||
|
||||
from InvenTree.helpers import str2bool, DownloadFile, GetExportFormats
|
||||
from InvenTree.helpers import ExtractSerialNumbers
|
||||
@ -26,19 +27,12 @@ from datetime import datetime
|
||||
|
||||
from company.models import Company, SupplierPart
|
||||
from part.models import Part
|
||||
from report.models import TestReport
|
||||
from .models import StockItem, StockLocation, StockItemTracking, StockItemAttachment, StockItemTestResult
|
||||
|
||||
from .admin import StockItemResource
|
||||
|
||||
from .forms import EditStockLocationForm
|
||||
from .forms import CreateStockItemForm
|
||||
from .forms import EditStockItemForm
|
||||
from .forms import AdjustStockForm
|
||||
from .forms import TrackingEntryForm
|
||||
from .forms import SerializeStockForm
|
||||
from .forms import ExportOptionsForm
|
||||
from .forms import EditStockItemAttachmentForm
|
||||
from .forms import EditStockItemTestResultForm
|
||||
from . import forms as StockForms
|
||||
|
||||
|
||||
class StockIndex(ListView):
|
||||
@ -113,7 +107,7 @@ class StockLocationEdit(AjaxUpdateView):
|
||||
"""
|
||||
|
||||
model = StockLocation
|
||||
form_class = EditStockLocationForm
|
||||
form_class = StockForms.EditStockLocationForm
|
||||
context_object_name = 'location'
|
||||
ajax_template_name = 'modal_form.html'
|
||||
ajax_form_title = _('Edit Stock Location')
|
||||
@ -157,7 +151,7 @@ class StockItemAttachmentCreate(AjaxCreateView):
|
||||
"""
|
||||
|
||||
model = StockItemAttachment
|
||||
form_class = EditStockItemAttachmentForm
|
||||
form_class = StockForms.EditStockItemAttachmentForm
|
||||
ajax_form_title = _("Add Stock Item Attachment")
|
||||
ajax_template_name = "modal_form.html"
|
||||
|
||||
@ -202,7 +196,7 @@ class StockItemAttachmentEdit(AjaxUpdateView):
|
||||
"""
|
||||
|
||||
model = StockItemAttachment
|
||||
form_class = EditStockItemAttachmentForm
|
||||
form_class = StockForms.EditStockItemAttachmentForm
|
||||
ajax_form_title = _("Edit Stock Item Attachment")
|
||||
|
||||
def get_form(self):
|
||||
@ -229,13 +223,48 @@ class StockItemAttachmentDelete(AjaxDeleteView):
|
||||
}
|
||||
|
||||
|
||||
class StockItemDeleteTestData(AjaxUpdateView):
|
||||
"""
|
||||
View for deleting all test data
|
||||
"""
|
||||
|
||||
model = StockItem
|
||||
form_class = ConfirmForm
|
||||
ajax_form_title = _("Delete All Test Data")
|
||||
|
||||
def get_form(self):
|
||||
return ConfirmForm()
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
valid = False
|
||||
|
||||
stock_item = StockItem.objects.get(pk=self.kwargs['pk'])
|
||||
form = self.get_form()
|
||||
|
||||
confirm = str2bool(request.POST.get('confirm', False))
|
||||
|
||||
if confirm is not True:
|
||||
form.errors['confirm'] = [_('Confirm test data deletion')]
|
||||
form.non_field_errors = [_('Check the confirmation box')]
|
||||
else:
|
||||
stock_item.test_results.all().delete()
|
||||
valid = True
|
||||
|
||||
data = {
|
||||
'form_valid': valid,
|
||||
}
|
||||
|
||||
return self.renderJsonResponse(request, form, data)
|
||||
|
||||
|
||||
class StockItemTestResultCreate(AjaxCreateView):
|
||||
"""
|
||||
View for adding a new StockItemTestResult
|
||||
"""
|
||||
|
||||
model = StockItemTestResult
|
||||
form_class = EditStockItemTestResultForm
|
||||
form_class = StockForms.EditStockItemTestResultForm
|
||||
ajax_form_title = _("Add Test Result")
|
||||
|
||||
def post_save(self, **kwargs):
|
||||
@ -263,17 +292,6 @@ class StockItemTestResultCreate(AjaxCreateView):
|
||||
form = super().get_form()
|
||||
form.fields['stock_item'].widget = HiddenInput()
|
||||
|
||||
# Extract the StockItem object
|
||||
item_id = form['stock_item'].value()
|
||||
|
||||
# Limit the options for the file attachments
|
||||
try:
|
||||
stock_item = StockItem.objects.get(pk=item_id)
|
||||
form.fields['attachment'].queryset = stock_item.attachments.all()
|
||||
except (ValueError, StockItem.DoesNotExist):
|
||||
# Hide the attachments field
|
||||
form.fields['attachment'].widget = HiddenInput()
|
||||
|
||||
return form
|
||||
|
||||
|
||||
@ -283,7 +301,7 @@ class StockItemTestResultEdit(AjaxUpdateView):
|
||||
"""
|
||||
|
||||
model = StockItemTestResult
|
||||
form_class = EditStockItemTestResultForm
|
||||
form_class = StockForms.EditStockItemTestResultForm
|
||||
ajax_form_title = _("Edit Test Result")
|
||||
|
||||
def get_form(self):
|
||||
@ -291,8 +309,6 @@ class StockItemTestResultEdit(AjaxUpdateView):
|
||||
form = super().get_form()
|
||||
|
||||
form.fields['stock_item'].widget = HiddenInput()
|
||||
|
||||
form.fields['attachment'].queryset = self.object.stock_item.attachments.all()
|
||||
|
||||
return form
|
||||
|
||||
@ -307,12 +323,81 @@ class StockItemTestResultDelete(AjaxDeleteView):
|
||||
context_object_name = "result"
|
||||
|
||||
|
||||
class StockItemTestReportSelect(AjaxView):
|
||||
"""
|
||||
View for selecting a TestReport template,
|
||||
and generating a TestReport as a PDF.
|
||||
"""
|
||||
|
||||
model = StockItem
|
||||
ajax_form_title = _("Select Test Report Template")
|
||||
|
||||
def get_form(self):
|
||||
|
||||
stock_item = StockItem.objects.get(pk=self.kwargs['pk'])
|
||||
return StockForms.TestReportFormatForm(stock_item)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
template_id = request.POST.get('template', None)
|
||||
|
||||
try:
|
||||
template = TestReport.objects.get(pk=template_id)
|
||||
except (ValueError, TestReport.DoesNoteExist):
|
||||
raise ValidationError({'template': _("Select valid template")})
|
||||
|
||||
stock_item = StockItem.objects.get(pk=self.kwargs['pk'])
|
||||
|
||||
url = reverse('stock-item-test-report-download')
|
||||
|
||||
url += '?stock_item={id}'.format(id=stock_item.pk)
|
||||
url += '&template={id}'.format(id=template.pk)
|
||||
|
||||
data = {
|
||||
'form_valid': True,
|
||||
'url': url,
|
||||
}
|
||||
|
||||
return self.renderJsonResponse(request, self.get_form(), data=data)
|
||||
|
||||
|
||||
class StockItemTestReportDownload(AjaxView):
|
||||
"""
|
||||
Download a TestReport against a StockItem.
|
||||
|
||||
Requires the following arguments to be passed as URL params:
|
||||
|
||||
stock_item - Valid PK of a StockItem object
|
||||
template - Valid PK of a TestReport template object
|
||||
|
||||
"""
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
|
||||
template = request.GET.get('template', None)
|
||||
stock_item = request.GET.get('stock_item', None)
|
||||
|
||||
try:
|
||||
template = TestReport.objects.get(pk=template)
|
||||
except (ValueError, TestReport.DoesNotExist):
|
||||
raise ValidationError({'template': 'Invalid template ID'})
|
||||
|
||||
try:
|
||||
stock_item = StockItem.objects.get(pk=stock_item)
|
||||
except (ValueError, StockItem.DoesNotExist):
|
||||
raise ValidationError({'stock_item': 'Invalid StockItem ID'})
|
||||
|
||||
template.stock_item = stock_item
|
||||
|
||||
return template.render(request)
|
||||
|
||||
|
||||
class StockExportOptions(AjaxView):
|
||||
""" Form for selecting StockExport options """
|
||||
|
||||
model = StockLocation
|
||||
ajax_form_title = _('Stock Export Options')
|
||||
form_class = ExportOptionsForm
|
||||
form_class = StockForms.ExportOptionsForm
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
@ -455,7 +540,7 @@ class StockAdjust(AjaxView, FormMixin):
|
||||
|
||||
ajax_template_name = 'stock/stock_adjust.html'
|
||||
ajax_form_title = _('Adjust Stock')
|
||||
form_class = AdjustStockForm
|
||||
form_class = StockForms.AdjustStockForm
|
||||
stock_items = []
|
||||
|
||||
def get_GET_items(self):
|
||||
@ -773,7 +858,7 @@ class StockItemEdit(AjaxUpdateView):
|
||||
"""
|
||||
|
||||
model = StockItem
|
||||
form_class = EditStockItemForm
|
||||
form_class = StockForms.EditStockItemForm
|
||||
context_object_name = 'item'
|
||||
ajax_template_name = 'modal_form.html'
|
||||
ajax_form_title = _('Edit Stock Item')
|
||||
@ -802,6 +887,30 @@ class StockItemEdit(AjaxUpdateView):
|
||||
return form
|
||||
|
||||
|
||||
class StockItemConvert(AjaxUpdateView):
|
||||
"""
|
||||
View for 'converting' a StockItem to a variant of its current part.
|
||||
"""
|
||||
|
||||
model = StockItem
|
||||
form_class = StockForms.ConvertStockItemForm
|
||||
ajax_form_title = _('Convert Stock Item')
|
||||
ajax_template_name = 'stock/stockitem_convert.html'
|
||||
context_object_name = 'item'
|
||||
|
||||
def get_form(self):
|
||||
"""
|
||||
Filter the available parts.
|
||||
"""
|
||||
|
||||
form = super().get_form()
|
||||
item = self.get_object()
|
||||
|
||||
form.fields['part'].queryset = item.part.get_all_variants()
|
||||
|
||||
return form
|
||||
|
||||
|
||||
class StockLocationCreate(AjaxCreateView):
|
||||
"""
|
||||
View for creating a new StockLocation
|
||||
@ -809,7 +918,7 @@ class StockLocationCreate(AjaxCreateView):
|
||||
"""
|
||||
|
||||
model = StockLocation
|
||||
form_class = EditStockLocationForm
|
||||
form_class = StockForms.EditStockLocationForm
|
||||
context_object_name = 'location'
|
||||
ajax_template_name = 'modal_form.html'
|
||||
ajax_form_title = _('Create new Stock Location')
|
||||
@ -834,7 +943,7 @@ class StockItemSerialize(AjaxUpdateView):
|
||||
model = StockItem
|
||||
ajax_template_name = 'stock/item_serialize.html'
|
||||
ajax_form_title = _('Serialize Stock')
|
||||
form_class = SerializeStockForm
|
||||
form_class = StockForms.SerializeStockForm
|
||||
|
||||
def get_form(self):
|
||||
|
||||
@ -843,7 +952,7 @@ class StockItemSerialize(AjaxUpdateView):
|
||||
# Pass the StockItem object through to the form
|
||||
context['item'] = self.get_object()
|
||||
|
||||
form = SerializeStockForm(**context)
|
||||
form = StockForms.SerializeStockForm(**context)
|
||||
|
||||
return form
|
||||
|
||||
@ -922,11 +1031,41 @@ class StockItemCreate(AjaxCreateView):
|
||||
"""
|
||||
|
||||
model = StockItem
|
||||
form_class = CreateStockItemForm
|
||||
form_class = StockForms.CreateStockItemForm
|
||||
context_object_name = 'item'
|
||||
ajax_template_name = 'modal_form.html'
|
||||
ajax_form_title = _('Create new Stock Item')
|
||||
|
||||
def get_part(self, form=None):
|
||||
"""
|
||||
Attempt to get the "part" associted with this new stockitem.
|
||||
|
||||
- May be passed to the form as a query parameter (e.g. ?part=<id>)
|
||||
- May be passed via the form field itself.
|
||||
"""
|
||||
|
||||
# Try to extract from the URL query
|
||||
part_id = self.request.GET.get('part', None)
|
||||
|
||||
if part_id:
|
||||
try:
|
||||
part = Part.objects.get(pk=part_id)
|
||||
return part
|
||||
except (Part.DoesNotExist, ValueError):
|
||||
pass
|
||||
|
||||
# Try to get from the form
|
||||
if form:
|
||||
try:
|
||||
part_id = form['part'].value()
|
||||
part = Part.objects.get(pk=part_id)
|
||||
return part
|
||||
except (Part.DoesNotExist, ValueError):
|
||||
pass
|
||||
|
||||
# Could not extract a part object
|
||||
return None
|
||||
|
||||
def get_form(self):
|
||||
""" Get form for StockItem creation.
|
||||
Overrides the default get_form() method to intelligently limit
|
||||
@ -935,53 +1074,44 @@ class StockItemCreate(AjaxCreateView):
|
||||
|
||||
form = super().get_form()
|
||||
|
||||
part = None
|
||||
part = self.get_part(form=form)
|
||||
|
||||
# If the user has selected a Part, limit choices for SupplierPart
|
||||
if form['part'].value():
|
||||
part_id = form['part'].value()
|
||||
if part is not None:
|
||||
sn = part.getNextSerialNumber()
|
||||
form.field_placeholder['serial_numbers'] = _('Next available serial number is') + ' ' + str(sn)
|
||||
|
||||
try:
|
||||
part = Part.objects.get(id=part_id)
|
||||
|
||||
sn = part.getNextSerialNumber()
|
||||
form.field_placeholder['serial_numbers'] = _('Next available serial number is') + ' ' + str(sn)
|
||||
form.rebuild_layout()
|
||||
|
||||
form.rebuild_layout()
|
||||
# Hide the 'part' field (as a valid part is selected)
|
||||
form.fields['part'].widget = HiddenInput()
|
||||
|
||||
# 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')
|
||||
|
||||
# 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
|
||||
parts = parts.filter(part=part.id)
|
||||
|
||||
# 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
|
||||
parts = parts.filter(part=part.id)
|
||||
form.fields['supplier_part'].queryset = parts
|
||||
|
||||
form.fields['supplier_part'].queryset = parts
|
||||
# If there is one (and only one) supplier part available, pre-select it
|
||||
all_parts = parts.all()
|
||||
|
||||
# If there is one (and only one) supplier part available, pre-select it
|
||||
all_parts = parts.all()
|
||||
if len(all_parts) == 1:
|
||||
|
||||
if len(all_parts) == 1:
|
||||
|
||||
# TODO - This does NOT work for some reason? Ref build.views.BuildItemCreate
|
||||
form.fields['supplier_part'].initial = all_parts[0].id
|
||||
|
||||
except Part.DoesNotExist:
|
||||
pass
|
||||
# TODO - This does NOT work for some reason? Ref build.views.BuildItemCreate
|
||||
form.fields['supplier_part'].initial = all_parts[0].id
|
||||
|
||||
# Otherwise if the user has selected a SupplierPart, we know what Part they meant!
|
||||
elif form['supplier_part'].value() is not None:
|
||||
if form['supplier_part'].value() is not None:
|
||||
pass
|
||||
|
||||
return form
|
||||
@ -1004,27 +1134,20 @@ class StockItemCreate(AjaxCreateView):
|
||||
else:
|
||||
initials = super(StockItemCreate, self).get_initial().copy()
|
||||
|
||||
part_id = self.request.GET.get('part', None)
|
||||
part = self.get_part()
|
||||
|
||||
loc_id = self.request.GET.get('location', None)
|
||||
sup_part_id = self.request.GET.get('supplier_part', None)
|
||||
|
||||
part = None
|
||||
location = None
|
||||
supplier_part = None
|
||||
|
||||
# Part field has been specified
|
||||
if part_id:
|
||||
try:
|
||||
part = Part.objects.get(pk=part_id)
|
||||
|
||||
# Check that the supplied part is 'valid'
|
||||
if not part.is_template and part.active and not part.virtual:
|
||||
initials['part'] = part
|
||||
initials['location'] = part.get_default_location()
|
||||
initials['supplier_part'] = part.default_supplier
|
||||
|
||||
except (ValueError, Part.DoesNotExist):
|
||||
pass
|
||||
if part is not None:
|
||||
# Check that the supplied part is 'valid'
|
||||
if not part.is_template and part.active and not part.virtual:
|
||||
initials['part'] = part
|
||||
initials['location'] = part.get_default_location()
|
||||
initials['supplier_part'] = part.default_supplier
|
||||
|
||||
# SupplierPart field has been specified
|
||||
# It must match the Part, if that has been supplied
|
||||
@ -1229,7 +1352,7 @@ class StockItemTrackingEdit(AjaxUpdateView):
|
||||
|
||||
model = StockItemTracking
|
||||
ajax_form_title = _('Edit Stock Tracking Entry')
|
||||
form_class = TrackingEntryForm
|
||||
form_class = StockForms.TrackingEntryForm
|
||||
|
||||
|
||||
class StockItemTrackingCreate(AjaxCreateView):
|
||||
@ -1238,7 +1361,7 @@ class StockItemTrackingCreate(AjaxCreateView):
|
||||
|
||||
model = StockItemTracking
|
||||
ajax_form_title = _("Add Stock Tracking Entry")
|
||||
form_class = TrackingEntryForm
|
||||
form_class = StockForms.TrackingEntryForm
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
|
Reference in New Issue
Block a user