2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-30 20:46:47 +00:00

Merge pull request #811 from SchrodingersGat/stock-item-testing

Stock item testing
This commit is contained in:
Oliver 2020-05-17 00:37:53 +10:00 committed by GitHub
commit c54cb2b280
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 825 additions and 64 deletions

View File

@ -81,6 +81,7 @@ dynamic_javascript_urls = [
url(r'^order.js', DynamicJsView.as_view(template_name='js/order.html'), name='order.js'), url(r'^order.js', DynamicJsView.as_view(template_name='js/order.html'), name='order.js'),
url(r'^company.js', DynamicJsView.as_view(template_name='js/company.html'), name='company.js'), url(r'^company.js', DynamicJsView.as_view(template_name='js/company.html'), name='company.js'),
url(r'^bom.js', DynamicJsView.as_view(template_name='js/bom.html'), name='bom.js'), url(r'^bom.js', DynamicJsView.as_view(template_name='js/bom.html'), name='bom.js'),
url(r'^table_filters.js', DynamicJsView.as_view(template_name='js/table_filters.html'), name='table_filters.js'),
] ]
urlpatterns = [ urlpatterns = [

View File

@ -198,8 +198,8 @@ InvenTree | Allocate Parts
var html = `<div class='btn-group float-right' role='group'>`; var html = `<div class='btn-group float-right' role='group'>`;
{% if build.status == BuildStatus.PENDING %} {% if build.status == BuildStatus.PENDING %}
html += makeIconButton('fa-edit', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}'); html += makeIconButton('fa-edit icon-blue', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}');
html += makeIconButton('fa-trash-alt', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}'); html += makeIconButton('fa-trash-alt icon-red', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}');
{% endif %} {% endif %}
html += `</div>`; html += `</div>`;
@ -389,7 +389,7 @@ InvenTree | Allocate Parts
html += makeIconButton('fa-tools', 'button-build', pk, '{% trans "Build parts" %}'); html += makeIconButton('fa-tools', 'button-build', pk, '{% trans "Build parts" %}');
} }
html += makeIconButton('fa-plus', 'button-add', pk, '{% trans "Allocate stock" %}'); html += makeIconButton('fa-plus icon-green', 'button-add', pk, '{% trans "Allocate stock" %}');
{% endif %} {% endif %}
html += '</div>'; html += '</div>';

View File

@ -208,7 +208,7 @@ $("#po-table").inventreeTable({
var pk = row.pk; var pk = row.pk;
{% if order.status == PurchaseOrderStatus.PENDING %} {% if order.status == PurchaseOrderStatus.PENDING %}
html += makeIconButton('fa-edit', 'button-line-edit', pk, '{% trans "Edit line item" %}'); html += makeIconButton('fa-edit icon-blue', 'button-line-edit', pk, '{% trans "Edit line item" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete line item" %}'); html += makeIconButton('fa-trash-alt icon-red', 'button-line-delete', pk, '{% trans "Delete line item" %}');
{% endif %} {% endif %}

View File

@ -89,8 +89,8 @@ function showAllocationSubTable(index, row, element) {
var pk = row.pk; var pk = row.pk;
{% if order.status == SalesOrderStatus.PENDING %} {% if order.status == SalesOrderStatus.PENDING %}
html += makeIconButton('fa-edit', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}'); html += makeIconButton('fa-edit icon-blue', 'button-allocation-edit', pk, '{% trans "Edit stock allocation" %}');
html += makeIconButton('fa-trash-alt', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}'); html += makeIconButton('fa-trash-alt icon-red', 'button-allocation-delete', pk, '{% trans "Delete stock allocation" %}');
{% endif %} {% endif %}
html += "</div>"; html += "</div>";
@ -274,11 +274,11 @@ $("#so-lines-table").inventreeTable({
html += makeIconButton('fa-tools', 'button-build', row.part, '{% trans "Build parts" %}'); html += makeIconButton('fa-tools', 'button-build', row.part, '{% trans "Build parts" %}');
} }
html += makeIconButton('fa-plus', 'button-add', pk, '{% trans "Allocate parts" %}'); html += makeIconButton('fa-plus icon-green', 'button-add', pk, '{% trans "Allocate parts" %}');
} }
html += makeIconButton('fa-edit', 'button-edit', pk, '{% trans "Edit line item" %}'); html += makeIconButton('fa-edit icon-blue', 'button-edit', pk, '{% trans "Edit line item" %}');
html += makeIconButton('fa-trash-alt', 'button-delete', pk, '{% trans "Delete line item " %}'); html += makeIconButton('fa-trash-alt icon-red', 'button-delete', pk, '{% trans "Delete line item " %}');
html += `</div>`; html += `</div>`;

View File

@ -10,6 +10,7 @@ import import_export.widgets as widgets
from .models import StockLocation, StockItem, StockItemAttachment from .models import StockLocation, StockItem, StockItemAttachment
from .models import StockItemTracking from .models import StockItemTracking
from .models import StockItemTestResult
from build.models import Build from build.models import Build
from company.models import SupplierPart from company.models import SupplierPart
@ -117,7 +118,13 @@ class StockTrackingAdmin(ImportExportModelAdmin):
list_display = ('item', 'date', 'title') list_display = ('item', 'date', 'title')
class StockItemTestResultAdmin(admin.ModelAdmin):
list_display = ('stock_item', 'test', 'result', 'value')
admin.site.register(StockLocation, LocationAdmin) admin.site.register(StockLocation, LocationAdmin)
admin.site.register(StockItem, StockItemAdmin) admin.site.register(StockItem, StockItemAdmin)
admin.site.register(StockItemTracking, StockTrackingAdmin) admin.site.register(StockItemTracking, StockTrackingAdmin)
admin.site.register(StockItemAttachment, StockAttachmentAdmin) admin.site.register(StockItemAttachment, StockAttachmentAdmin)
admin.site.register(StockItemTestResult, StockItemTestResultAdmin)

View File

@ -15,6 +15,7 @@ from django.db.models import Q
from .models import StockLocation, StockItem from .models import StockLocation, StockItem
from .models import StockItemTracking from .models import StockItemTracking
from .models import StockItemAttachment from .models import StockItemAttachment
from .models import StockItemTestResult
from part.models import Part, PartCategory from part.models import Part, PartCategory
from part.serializers import PartBriefSerializer from part.serializers import PartBriefSerializer
@ -26,6 +27,7 @@ from .serializers import StockItemSerializer
from .serializers import LocationSerializer, LocationBriefSerializer from .serializers import LocationSerializer, LocationBriefSerializer
from .serializers import StockTrackingSerializer from .serializers import StockTrackingSerializer
from .serializers import StockItemAttachmentSerializer from .serializers import StockItemAttachmentSerializer
from .serializers import StockItemTestResultSerializer
from InvenTree.views import TreeSerializer from InvenTree.views import TreeSerializer
from InvenTree.helpers import str2bool, isNull from InvenTree.helpers import str2bool, isNull
@ -536,11 +538,10 @@ class StockList(generics.ListCreateAPIView):
try: try:
part = Part.objects.get(pk=part_id) part = Part.objects.get(pk=part_id)
# If the part is a Template part, select stock items for any "variant" parts under that template # Filter by any parts "under" the given part
if part.is_template: parts = part.get_descendants(include_self=True)
queryset = queryset.filter(part__in=[part.id for part in Part.objects.filter(variant_of=part_id)])
else: queryset = queryset.filter(part__in=parts)
queryset = queryset.filter(part=part_id)
except (ValueError, Part.DoesNotExist): except (ValueError, Part.DoesNotExist):
raise ValidationError({"part": "Invalid Part ID specified"}) raise ValidationError({"part": "Invalid Part ID specified"})
@ -654,11 +655,89 @@ class StockAttachmentList(generics.ListCreateAPIView, AttachmentMixin):
queryset = StockItemAttachment.objects.all() queryset = StockItemAttachment.objects.all()
serializer_class = StockItemAttachmentSerializer serializer_class = StockItemAttachmentSerializer
filter_backends = [
DjangoFilterBackend,
filters.OrderingFilter,
filters.SearchFilter,
]
filter_fields = [ filter_fields = [
'stock_item', 'stock_item',
] ]
class StockItemTestResultList(generics.ListCreateAPIView):
"""
API endpoint for listing (and creating) a StockItemTestResult object.
"""
queryset = StockItemTestResult.objects.all()
serializer_class = StockItemTestResultSerializer
permission_classes = [
permissions.IsAuthenticated,
]
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
filter_fields = [
'stock_item',
'test',
'user',
'result',
'value',
]
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)
def perform_create(self, serializer):
"""
Create a new test result object.
Also, check if an attachment was uploaded alongside the test result,
and save it to the database if it were.
"""
# 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()
class StockTrackingList(generics.ListCreateAPIView): class StockTrackingList(generics.ListCreateAPIView):
""" API endpoint for list view of StockItemTracking objects. """ API endpoint for list view of StockItemTracking objects.
@ -769,6 +848,11 @@ stock_api_urls = [
url(r'^$', StockAttachmentList.as_view(), name='api-stock-attachment-list'), url(r'^$', StockAttachmentList.as_view(), name='api-stock-attachment-list'),
])), ])),
# Base URL for StockItemTestResult API endpoints
url(r'^test/', include([
url(r'^$', StockItemTestResultList.as_view(), name='api-stock-test-result-list'),
])),
url(r'track/?', StockTrackingList.as_view(), name='api-stock-track'), url(r'track/?', StockTrackingList.as_view(), name='api-stock-track'),
url(r'^tree/?', StockCategoryTree.as_view(), name='api-stock-tree'), url(r'^tree/?', StockCategoryTree.as_view(), name='api-stock-tree'),

View File

@ -70,6 +70,18 @@
lft: 0 lft: 0
rght: 0 rght: 0
- model: stock.stockitem
pk: 105
fields:
part: 25
location: 7
quantity: 1
serial: 1000
level: 0
tree_id: 0
lft: 0
rght: 0
# Stock items for template / variant parts # Stock items for template / variant parts
- model: stock.stockitem - model: stock.stockitem
pk: 500 pk: 500

View File

@ -0,0 +1,31 @@
- model: stock.stockitemtestresult
fields:
stock_item: 105
test: "Firmware Version"
value: "0xA1B2C3D4"
result: True
date: 2020-02-02
- model: stock.stockitemtestresult
fields:
stock_item: 105
test: "Settings Checksum"
value: "0xAABBCCDD"
result: True
date: 2020-02-02
- model: stock.stockitemtestresult
fields:
stock_item: 105
test: "Temperature Test"
result: False
date: 2020-05-16
notes: 'Got too hot or something'
- model: stock.stockitemtestresult
fields:
stock_item: 105
test: "Temperature Test"
result: True
date: 2020-05-17
notes: 'Passed temperature test by making it cooler'

View File

@ -15,7 +15,9 @@ from InvenTree.helpers import GetExportFormats
from InvenTree.forms import HelperForm from InvenTree.forms import HelperForm
from InvenTree.fields import RoundingDecimalFormField from InvenTree.fields import RoundingDecimalFormField
from .models import StockLocation, StockItem, StockItemTracking, StockItemAttachment from .models import StockLocation, StockItem, StockItemTracking
from .models import StockItemAttachment
from .models import StockItemTestResult
class EditStockItemAttachmentForm(HelperForm): class EditStockItemAttachmentForm(HelperForm):
@ -32,6 +34,22 @@ class EditStockItemAttachmentForm(HelperForm):
] ]
class EditStockItemTestResultForm(HelperForm):
"""
Form for creating / editing a StockItemTestResult object.
"""
class Meta:
model = StockItemTestResult
fields = [
'stock_item',
'test',
'result',
'value',
'notes',
]
class EditStockLocationForm(HelperForm): class EditStockLocationForm(HelperForm):
""" Form for editing a StockLocation """ """ Form for editing a StockLocation """

View File

@ -0,0 +1,29 @@
# Generated by Django 3.0.5 on 2020-05-16 09:55
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('stock', '0039_auto_20200513_0016'),
]
operations = [
migrations.CreateModel(
name='StockItemTestResult',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('test', models.CharField(help_text='Test name', max_length=100, verbose_name='Test')),
('result', models.BooleanField(default=False, help_text='Test result', verbose_name='Result')),
('value', models.CharField(blank=True, help_text='Test output value', max_length=500, verbose_name='Value')),
('date', models.DateTimeField(auto_now_add=True)),
('attachment', models.ForeignKey(blank=True, help_text='Test result attachment', null=True, on_delete=django.db.models.deletion.SET_NULL, to='stock.StockItemAttachment', verbose_name='Attachment')),
('stock_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='test_results', to='stock.StockItem')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.0.5 on 2020-05-16 10:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stock', '0040_stockitemtestresult'),
]
operations = [
migrations.AddField(
model_name='stockitemtestresult',
name='notes',
field=models.CharField(blank=True, help_text='Test notes', max_length=500, verbose_name='Notes'),
),
]

View File

@ -921,6 +921,51 @@ class StockItem(MPTTModel):
return s return s
def getTestResults(self, test=None, result=None, user=None):
"""
Return all test results associated with this StockItem.
Optionally can filter results by:
- Test name
- Test result
- User
"""
results = self.test_results
if test:
# Filter by test name
results = results.filter(test=test)
if result is not None:
# Filter by test status
results = results.filter(result=result)
if user:
# Filter by user
results = results.filter(user=user)
return results
def testResultMap(self, **kwargs):
"""
Return a map of test-results using the test name as the key.
Where multiple test results exist for a given name,
the *most recent* test is used.
This map is useful for rendering to a template (e.g. a test report),
as all named tests are accessible.
"""
results = self.getTestResults(**kwargs).order_by('-date')
result_map = {}
for result in results:
result_map[result.test] = result
return result_map
@receiver(pre_delete, sender=StockItem, dispatch_uid='stock_item_pre_delete_log') @receiver(pre_delete, sender=StockItem, dispatch_uid='stock_item_pre_delete_log')
def before_delete_stock_item(sender, instance, using, **kwargs): def before_delete_stock_item(sender, instance, using, **kwargs):
@ -993,3 +1038,86 @@ class StockItemTracking(models.Model):
# TODO # TODO
# file = models.FileField() # file = models.FileField()
class StockItemTestResult(models.Model):
"""
A StockItemTestResult records results of custom tests against individual StockItem objects.
This is useful for tracking unit acceptance tests, and particularly useful when integrated
with automated testing setups.
Multiple results can be recorded against any given test, allowing tests to be run many times.
Attributes:
stock_item: Link to StockItem
test: Test name (simple string matching)
result: Test result value (pass / fail / etc)
value: Recorded test output value (optional)
attachment: Link to StockItem attachment (optional)
notes: Extra user notes related to the test (optional)
user: User who uploaded the test result
date: Date the test result was recorded
"""
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
stock_item = models.ForeignKey(
StockItem,
on_delete=models.CASCADE,
related_name='test_results'
)
test = models.CharField(
blank=False, max_length=100,
verbose_name=_('Test'),
help_text=_('Test name')
)
result = models.BooleanField(
default=False,
verbose_name=_('Result'),
help_text=_('Test result')
)
value = models.CharField(
blank=True, max_length=500,
verbose_name=_('Value'),
help_text=_('Test output value')
)
attachment = models.ForeignKey(
StockItemAttachment,
on_delete=models.SET_NULL,
blank=True, null=True,
verbose_name=_('Attachment'),
help_text=_('Test result attachment'),
)
notes = models.CharField(
blank=True, max_length=500,
verbose_name=_('Notes'),
help_text=_("Test notes"),
)
user = models.ForeignKey(
User,
on_delete=models.SET_NULL,
blank=True, null=True
)
date = models.DateTimeField(
auto_now_add=True,
editable=False
)

View File

@ -7,6 +7,7 @@ from rest_framework import serializers
from .models import StockItem, StockLocation from .models import StockItem, StockLocation
from .models import StockItemTracking from .models import StockItemTracking
from .models import StockItemAttachment from .models import StockItemAttachment
from .models import StockItemTestResult
from django.db.models import Sum, Count from django.db.models import Sum, Count
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
@ -193,7 +194,7 @@ class LocationSerializer(InvenTreeModelSerializer):
class StockItemAttachmentSerializer(InvenTreeModelSerializer): class StockItemAttachmentSerializer(InvenTreeModelSerializer):
""" Serializer for StockItemAttachment model """ """ Serializer for StockItemAttachment model """
def __init_(self, *args, **kwargs): def __init__(self, *args, **kwargs):
user_detail = kwargs.pop('user_detail', False) user_detail = kwargs.pop('user_detail', False)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -211,11 +212,55 @@ class StockItemAttachmentSerializer(InvenTreeModelSerializer):
'stock_item', 'stock_item',
'attachment', 'attachment',
'comment', 'comment',
'upload_date',
'user', 'user',
'user_detail', 'user_detail',
] ]
class StockItemTestResultSerializer(InvenTreeModelSerializer):
""" Serializer for the StockItemTestResult model """
user_detail = UserSerializerBrief(source='user', read_only=True)
attachment_detail = StockItemAttachmentSerializer(source='attachment', 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',
'test',
'result',
'value',
'attachment',
'attachment_detail',
'notes',
'user',
'user_detail',
'date'
]
read_only_fields = [
'pk',
'attachment',
'user',
'date',
]
class StockTrackingSerializer(InvenTreeModelSerializer): class StockTrackingSerializer(InvenTreeModelSerializer):
""" Serializer for StockItemTracking model """ """ Serializer for StockItemTracking model """

View File

@ -8,8 +8,9 @@
{% include "stock/tabs.html" with tab="tracking" %} {% include "stock/tabs.html" with tab="tracking" %}
<hr>
<h4>{% trans "Stock Tracking Information" %}</h4> <h4>{% trans "Stock Tracking Information" %}</h4>
<hr>
<div id='table-toolbar'> <div id='table-toolbar'>
<div class='btn-group'> <div class='btn-group'>
<button class='btn btn-success' type='button' title='New tracking entry' id='new-entry'>New Entry</button> <button class='btn btn-success' type='button' title='New tracking entry' id='new-entry'>New Entry</button>

View File

@ -7,8 +7,8 @@
{% include "stock/tabs.html" with tab='attachments' %} {% include "stock/tabs.html" with tab='attachments' %}
<hr>
<h4>{% trans "Stock Item Attachments" %}</h4> <h4>{% trans "Stock Item Attachments" %}</h4>
<hr>
{% include "attachment_table.html" with attachments=item.attachments.all %} {% include "attachment_table.html" with attachments=item.attachments.all %}

View File

@ -10,6 +10,7 @@
{% include "stock/tabs.html" with tab="notes" %} {% include "stock/tabs.html" with tab="notes" %}
{% if editing %} {% if editing %}
<h4>{% trans "Stock Item Notes" %}</h4> <h4>{% trans "Stock Item Notes" %}</h4>
<hr> <hr>

View File

@ -0,0 +1,76 @@
{% extends "stock/item_base.html" %}
{% load static %}
{% load i18n %}
{% block details %}
{% include "stock/tabs.html" with tab='tests' %}
<h4>{% trans "Test Results" %}</h4>
<hr>
<div id='button-toolbar'>
<div class='button-toolbar container-fluid' style="float: right;">
<div class='btn-group' role='group'>
<button type='button' class='btn btn-success' id='add-test-result'>{% trans "Add Test Result" %}</button>
</div>
<div class='filter-list' id='filter-list-stocktests'>
<!-- Empty div -->
</div>
</div>
</div>
<table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='test-result-table'></table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
loadStockTestResultsTable(
$("#test-result-table"), {
params: {
stock_item: {{ item.id }},
user_detail: true,
attachment_detail: true,
},
}
);
function reloadTable() {
$("#test-result-table").bootstrapTable("refresh");
}
$("#add-test-result").click(function() {
launchModalForm(
"{% url 'stock-item-test-create' %}", {
data: {
stock_item: {{ item.id }},
},
success: reloadTable,
}
);
});
$("#test-result-table").on('click', '.button-test-edit', function() {
var button = $(this);
var url = `/stock/item/test/${button.attr('pk')}/edit/`;
launchModalForm(url, {
success: reloadTable,
});
});
$("#test-result-table").on('click', '.button-test-delete', function() {
var button = $(this);
var url = `/stock/item/test/${button.attr('pk')}/delete/`;
launchModalForm(url, {
success: reloadTable,
});
});
{% endblock %}

View File

@ -1,15 +1,20 @@
{% load i18n %} {% load i18n %}
<ul class='nav nav-tabs'> <ul class='nav nav-tabs'>
<li{% ifequal tab 'children' %} class='active'{% endifequal %}>
<a href="{% url 'stock-item-children' item.id %}">{% trans "Children" %}{% if item.child_count > 0 %}<span class='badge'>{{ item.child_count }}</span>{% endif %}</a>
</li>
<li{% ifequal tab 'tracking' %} class='active'{% endifequal %}> <li{% ifequal tab 'tracking' %} class='active'{% endifequal %}>
<a href="{% url 'stock-item-detail' item.id %}"> <a href="{% url 'stock-item-detail' item.id %}">
{% trans "Tracking" %} {% trans "Tracking" %}
{% if item.tracking_info.count > 0 %}<span class='badge'>{{ item.tracking_info.count }}</span>{% endif %} {% if item.tracking_info.count > 0 %}<span class='badge'>{{ item.tracking_info.count }}</span>{% endif %}
</a> </a>
</li> </li>
{% if item.part.trackable %}
<li{% if tab == 'tests' %} class='active'{% endif %}>
<a href="{% url 'stock-item-test-results' item.id %}">
{% trans "Test Results" %}
{% if item.test_results.count > 0 %}<span class='badge'>{{ item.test_results.count }}</span>{% endif %}
</a>
</li>
{% endif %}
{% if 0 %} {% if 0 %}
<!-- These tabs are to be implemented in the future --> <!-- These tabs are to be implemented in the future -->
<li{% ifequal tab 'builds' %} class='active'{% endifequal %}> <li{% ifequal tab 'builds' %} class='active'{% endifequal %}>
@ -28,4 +33,9 @@
{% if item.attachments.count > 0 %}<span class='badge'>{{ item.attachments.count }}</span>{% endif %} {% if item.attachments.count > 0 %}<span class='badge'>{{ item.attachments.count }}</span>{% endif %}
</a> </a>
</li> </li>
{% if item.child_count > 0 %}
<li{% ifequal tab 'children' %} class='active'{% endifequal %}>
<a href="{% url 'stock-item-children' item.id %}">{% trans "Children" %}{% if item.child_count > 0 %}<span class='badge'>{{ item.child_count }}</span>{% endif %}</a>
</li>
{% endif %}
</ul> </ul>

View File

@ -6,11 +6,17 @@ from django.contrib.auth import get_user_model
from .models import StockLocation from .models import StockLocation
class StockLocationTest(APITestCase): class StockAPITestCase(APITestCase):
"""
Series of API tests for the StockLocation API fixtures = [
""" 'category',
list_url = reverse('api-location-list') 'part',
'company',
'location',
'supplier_part',
'stock',
'stock_tests',
]
def setUp(self): def setUp(self):
# Create a user for auth # Create a user for auth
@ -18,6 +24,21 @@ class StockLocationTest(APITestCase):
User.objects.create_user('testuser', 'test@testing.com', 'password') User.objects.create_user('testuser', 'test@testing.com', 'password')
self.client.login(username='testuser', password='password') self.client.login(username='testuser', password='password')
def doPost(self, url, data={}):
response = self.client.post(url, data=data, format='json')
return response
class StockLocationTest(StockAPITestCase):
"""
Series of API tests for the StockLocation API
"""
list_url = reverse('api-location-list')
def setUp(self):
super().setUp()
# Add some stock locations # Add some stock locations
StockLocation.objects.create(name='top', description='top category') StockLocation.objects.create(name='top', description='top category')
@ -38,7 +59,7 @@ class StockLocationTest(APITestCase):
self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.status_code, status.HTTP_201_CREATED)
class StockItemTest(APITestCase): class StockItemTest(StockAPITestCase):
""" """
Series of API tests for the StockItem API Series of API tests for the StockItem API
""" """
@ -49,11 +70,7 @@ class StockItemTest(APITestCase):
return reverse('api-stock-detail', kwargs={'pk': pk}) return reverse('api-stock-detail', kwargs={'pk': pk})
def setUp(self): def setUp(self):
# Create a user for auth super().setUp()
User = get_user_model()
User.objects.create_user('testuser', 'test@testing.com', 'password')
self.client.login(username='testuser', password='password')
# Create some stock locations # Create some stock locations
top = StockLocation.objects.create(name='A', description='top') top = StockLocation.objects.create(name='A', description='top')
@ -65,30 +82,11 @@ class StockItemTest(APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
class StocktakeTest(APITestCase): class StocktakeTest(StockAPITestCase):
""" """
Series of tests for the Stocktake API Series of tests for the Stocktake API
""" """
fixtures = [
'category',
'part',
'company',
'location',
'supplier_part',
'stock',
]
def setUp(self):
User = get_user_model()
User.objects.create_user('testuser', 'test@testing.com', 'password')
self.client.login(username='testuser', password='password')
def doPost(self, url, data={}):
response = self.client.post(url, data=data, format='json')
return response
def test_action(self): def test_action(self):
""" """
Test each stocktake action endpoint, Test each stocktake action endpoint,
@ -179,3 +177,82 @@ class StocktakeTest(APITestCase):
response = self.doPost(url, data) response = self.doPost(url, data)
self.assertContains(response, 'Valid location must be specified', status_code=status.HTTP_400_BAD_REQUEST) self.assertContains(response, 'Valid location must be specified', status_code=status.HTTP_400_BAD_REQUEST)
class StockTestResultTest(StockAPITestCase):
def get_url(self):
return reverse('api-stock-test-result-list')
def test_list(self):
url = self.get_url()
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertGreaterEqual(len(response.data), 4)
response = self.client.get(url, data={'stock_item': 105})
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertGreaterEqual(len(response.data), 4)
def test_post_fail(self):
# Attempt to post a new test result without specifying required data
url = self.get_url()
response = self.client.post(
url,
data={
'test': 'A test',
'result': True,
},
format='json'
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
# This one should pass!
response = self.client.post(
url,
data={
'test': 'A test',
'stock_item': 105,
'result': True,
},
format='json'
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
def test_post(self):
# Test creation of a new test result
url = self.get_url()
response = self.client.get(url)
n = len(response.data)
data = {
'stock_item': 105,
'test': 'Checked Steam Valve',
'result': False,
'value': '150kPa',
'notes': 'I guess there was just too much pressure?',
}
response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
response = self.client.get(url)
self.assertEqual(len(response.data), n + 1)
# And read out again
response = self.client.get(url, data={'test': 'Checked Steam Valve'})
self.assertEqual(len(response.data), 1)
test = response.data[0]
self.assertEqual(test['value'], '150kPa')
self.assertEqual(test['user'], 1)

View File

@ -17,6 +17,7 @@ class StockTest(TestCase):
'part', 'part',
'location', 'location',
'stock', 'stock',
'stock_tests',
] ]
def setUp(self): def setUp(self):
@ -95,8 +96,8 @@ class StockTest(TestCase):
self.assertFalse(self.drawer2.has_items()) self.assertFalse(self.drawer2.has_items())
# Drawer 3 should have three stock items # Drawer 3 should have three stock items
self.assertEqual(self.drawer3.stock_items.count(), 15) self.assertEqual(self.drawer3.stock_items.count(), 16)
self.assertEqual(self.drawer3.item_count, 15) self.assertEqual(self.drawer3.item_count, 16)
def test_stock_count(self): def test_stock_count(self):
part = Part.objects.get(pk=1) part = Part.objects.get(pk=1)
@ -108,7 +109,7 @@ class StockTest(TestCase):
self.assertEqual(part.total_stock, 9000) self.assertEqual(part.total_stock, 9000)
# There should be 18 widgets in stock # There should be 18 widgets in stock
self.assertEqual(StockItem.objects.filter(part=25).aggregate(Sum('quantity'))['quantity__sum'], 18) self.assertEqual(StockItem.objects.filter(part=25).aggregate(Sum('quantity'))['quantity__sum'], 19)
def test_delete_location(self): def test_delete_location(self):
@ -168,12 +169,12 @@ class StockTest(TestCase):
self.assertEqual(w1.quantity, 4) self.assertEqual(w1.quantity, 4)
# There should also be a new object still in drawer3 # There should also be a new object still in drawer3
self.assertEqual(StockItem.objects.filter(part=25).count(), 4) self.assertEqual(StockItem.objects.filter(part=25).count(), 5)
widget = StockItem.objects.get(location=self.drawer3.id, part=25, quantity=4) widget = StockItem.objects.get(location=self.drawer3.id, part=25, quantity=4)
# Try to move negative units # Try to move negative units
self.assertFalse(widget.move(self.bathroom, 'Test', None, quantity=-100)) self.assertFalse(widget.move(self.bathroom, 'Test', None, quantity=-100))
self.assertEqual(StockItem.objects.filter(part=25).count(), 4) self.assertEqual(StockItem.objects.filter(part=25).count(), 5)
# Try to move to a blank location # Try to move to a blank location
self.assertFalse(widget.move(None, 'null', None)) self.assertFalse(widget.move(None, 'null', None))
@ -404,3 +405,29 @@ class VariantTest(StockTest):
item.serial += 1 item.serial += 1
item.save() item.save()
class TestResultTest(StockTest):
"""
Tests for the StockItemTestResult model.
"""
def test_test_count(self):
item = StockItem.objects.get(pk=105)
tests = item.test_results
self.assertEqual(tests.count(), 4)
results = item.getTestResults(test="Temperature Test")
self.assertEqual(results.count(), 2)
# Passing tests
self.assertEqual(item.getTestResults(result=True).count(), 3)
self.assertEqual(item.getTestResults(result=False).count(), 1)
# Result map
result_map = item.testResultMap()
self.assertEqual(len(result_map), 3)
for test in ['Firmware Version', 'Settings Checksum', 'Temperature Test']:
self.assertIn(test, result_map.keys())

View File

@ -24,6 +24,7 @@ stock_item_detail_urls = [
url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'), url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'),
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'^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'), url(r'^attachments/', views.StockItemDetail.as_view(template_name='stock/item_attachments.html'), name='stock-item-attachments'),
url(r'^notes/', views.StockItemNotes.as_view(), name='stock-item-notes'), url(r'^notes/', views.StockItemNotes.as_view(), name='stock-item-notes'),
@ -51,12 +52,20 @@ 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'),
# URLs for StockItem attachments
url(r'^item/attachment/', include([ url(r'^item/attachment/', include([
url(r'^new/', views.StockItemAttachmentCreate.as_view(), name='stock-item-attachment-create'), url(r'^new/', views.StockItemAttachmentCreate.as_view(), name='stock-item-attachment-create'),
url(r'^(?P<pk>\d+)/edit/', views.StockItemAttachmentEdit.as_view(), name='stock-item-attachment-edit'), url(r'^(?P<pk>\d+)/edit/', views.StockItemAttachmentEdit.as_view(), name='stock-item-attachment-edit'),
url(r'^(?P<pk>\d+)/delete/', views.StockItemAttachmentDelete.as_view(), name='stock-item-attachment-delete'), url(r'^(?P<pk>\d+)/delete/', views.StockItemAttachmentDelete.as_view(), name='stock-item-attachment-delete'),
])), ])),
# URLs for StockItem tests
url(r'^item/test/', include([
url(r'^new/', views.StockItemTestResultCreate.as_view(), name='stock-item-test-create'),
url(r'^(?P<pk>\d+)/edit/', views.StockItemTestResultEdit.as_view(), name='stock-item-test-edit'),
url(r'^(?P<pk>\d+)/delete/', views.StockItemTestResultDelete.as_view(), name='stock-item-test-delete'),
])),
url(r'^track/', include(stock_tracking_urls)), url(r'^track/', include(stock_tracking_urls)),
url(r'^adjust/?', views.StockAdjust.as_view(), name='stock-adjust'), url(r'^adjust/?', views.StockAdjust.as_view(), name='stock-adjust'),

View File

@ -26,7 +26,7 @@ from datetime import datetime
from company.models import Company, SupplierPart from company.models import Company, SupplierPart
from part.models import Part from part.models import Part
from .models import StockItem, StockLocation, StockItemTracking, StockItemAttachment from .models import StockItem, StockLocation, StockItemTracking, StockItemAttachment, StockItemTestResult
from .admin import StockItemResource from .admin import StockItemResource
@ -38,6 +38,7 @@ from .forms import TrackingEntryForm
from .forms import SerializeStockForm from .forms import SerializeStockForm
from .forms import ExportOptionsForm from .forms import ExportOptionsForm
from .forms import EditStockItemAttachmentForm from .forms import EditStockItemAttachmentForm
from .forms import EditStockItemTestResultForm
class StockIndex(ListView): class StockIndex(ListView):
@ -228,6 +229,69 @@ class StockItemAttachmentDelete(AjaxDeleteView):
} }
class StockItemTestResultCreate(AjaxCreateView):
"""
View for adding a new StockItemTestResult
"""
model = StockItemTestResult
form_class = EditStockItemTestResultForm
ajax_form_title = _("Add Test Result")
def post_save(self, **kwargs):
""" Record the user that uploaded the test result """
self.object.user = self.request.user
self.object.save()
def get_initial(self):
initials = super().get_initial()
try:
stock_id = self.request.GET.get('stock_item', None)
initials['stock_item'] = StockItem.objects.get(pk=stock_id)
except (ValueError, StockItem.DoesNotExist):
pass
return initials
def get_form(self):
form = super().get_form()
form.fields['stock_item'].widget = HiddenInput()
return form
class StockItemTestResultEdit(AjaxUpdateView):
"""
View for editing a StockItemTestResult
"""
model = StockItemTestResult
form_class = EditStockItemTestResultForm
ajax_form_title = _("Edit Test Result")
def get_form(self):
form = super().get_form()
form.fields['stock_item'].widget = HiddenInput()
return form
class StockItemTestResultDelete(AjaxDeleteView):
"""
View for deleting a StockItemTestResult
"""
model = StockItemTestResult
ajax_form_title = _("Delete Test Result")
context_object_name = "result"
class StockExportOptions(AjaxView): class StockExportOptions(AjaxView):
""" Form for selecting StockExport options """ """ Form for selecting StockExport options """

View File

@ -28,7 +28,7 @@
<td> <td>
<div class='btn-group' style='float: right;'> <div class='btn-group' style='float: right;'>
<button type='button' class='btn btn-default btn-glyph attachment-edit-button' pk="{{ attachment.id }}" data-toggle='tooltip' title='{% trans "Edit attachment" %}'> <button type='button' class='btn btn-default btn-glyph attachment-edit-button' pk="{{ attachment.id }}" data-toggle='tooltip' title='{% trans "Edit attachment" %}'>
<span class='fas fa-edit'/> <span class='fas fa-edit icon-blue'/>
</button> </button>
<button type='button' class='btn btn-default btn-glyph attachment-delete-button' pk="{{ attachment.id }}" data-toggle='tooltip' title='{% trans "Delete attachment" %}'> <button type='button' class='btn btn-default btn-glyph attachment-delete-button' pk="{{ attachment.id }}" data-toggle='tooltip' title='{% trans "Delete attachment" %}'>
<span class='fas fa-trash-alt icon-red'/> <span class='fas fa-trash-alt icon-red'/>

View File

@ -114,6 +114,7 @@ InvenTree
<script type='text/javascript' src="{% url 'stock.js' %}"></script> <script type='text/javascript' src="{% url 'stock.js' %}"></script>
<script type='text/javascript' src="{% url 'build.js' %}"></script> <script type='text/javascript' src="{% url 'build.js' %}"></script>
<script type='text/javascript' src="{% url 'order.js' %}"></script> <script type='text/javascript' src="{% url 'order.js' %}"></script>
<script type='text/javascript' src="{% url 'table_filters.js' %}"></script>
<script type='text/javascript' src="{% static 'fontawesome/js/solid.js' %}"></script> <script type='text/javascript' src="{% static 'fontawesome/js/solid.js' %}"></script>
<script type='text/javascript' src="{% static 'fontawesome/js/brands.js' %}"></script> <script type='text/javascript' src="{% static 'fontawesome/js/brands.js' %}"></script>
@ -122,8 +123,6 @@ InvenTree
{% block js_load %} {% block js_load %}
{% endblock %} {% endblock %}
{% include "table_filters.html" %}
<script type='text/javascript'> <script type='text/javascript'>
$(document).ready(function () { $(document).ready(function () {

View File

@ -18,6 +18,123 @@ function removeStockRow(e) {
$('#' + row).remove(); $('#' + row).remove();
} }
function passFailBadge(result) {
if (result) {
return `<span class='label label-green'>{% trans "PASS" %}</span>`;
} else {
return `<span class='label label-red'>{% trans "FAIL" %}</span>`;
}
}
function loadStockTestResultsTable(table, options) {
/*
* Load StockItemTestResult table
*/
var params = options.params || {};
// HTML element to setup the filtering
var filterListElement = options.filterList || '#filter-list-stocktests';
var filters = {};
filters = loadTableFilters("stocktests");
var original = {};
for (var key in params) {
original[key] = params[key];
}
setupFilterList("stocktests", table, filterListElement);
// Override the default values, or add new ones
for (var key in params) {
filters[key] = params[key];
}
table.inventreeTable({
method: 'get',
formatNoMatches: function() {
return '{% trans "No test results matching query" %}';
},
url: "{% url 'api-stock-test-result-list' %}",
queryParams: filters,
original: original,
columns: [
{
field: 'pk',
title: 'ID',
visible: false
},
{
field: 'test',
title: '{% trans "Test" %}',
sortable: true,
formatter: function(value, row) {
var html = value;
if (row.attachment_detail) {
html += `<a href='${row.attachment_detail.attachment}'><span class='fas fa-file-alt label-right'></span></a>`;
}
return html;
},
},
{
field: 'result',
title: "{% trans "Result" %}",
sortable: true,
formatter: function(value) {
return passFailBadge(value);
}
},
{
field: 'value',
title: "{% trans "Value" %}",
sortable: true,
},
{
field: 'notes',
title: '{% trans "Notes" %}',
},
{
field: 'date',
title: '{% trans "Uploaded" %}',
sortable: true,
formatter: function(value, row) {
var html = value;
if (row.user_detail) {
html += `<span class='badge'>${row.user_detail.username}</span>`;
}
return html;
}
},
{
field: 'buttons',
formatter: function(value, row) {
var pk = row.pk;
var html = `<div class='btn-group float-right' role='group'>`;
html += makeIconButton('fa-edit icon-blue', 'button-test-edit', pk, '{% trans "Edit test result" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-test-delete', pk, '{% trans "Delete test result" %}');
html += `</div>`;
return html;
}
},
]
});
}
function loadStockTable(table, options) { function loadStockTable(table, options) {
/* Load data into a stock table with adjustable options. /* Load data into a stock table with adjustable options.
* Fetches data (via AJAX) and loads into a bootstrap table. * Fetches data (via AJAX) and loads into a bootstrap table.

View File

@ -1,8 +1,6 @@
{% load i18n %} {% load i18n %}
{% load status_codes %} {% load status_codes %}
<script type='text/javascript'>
{% include "status_codes.html" with label='stock' options=StockStatus.list %} {% include "status_codes.html" with label='stock' options=StockStatus.list %}
{% include "status_codes.html" with label='build' options=BuildStatus.list %} {% include "status_codes.html" with label='build' options=BuildStatus.list %}
{% include "status_codes.html" with label='purchaseOrder' options=PurchaseOrderStatus.list %} {% include "status_codes.html" with label='purchaseOrder' options=PurchaseOrderStatus.list %}
@ -39,6 +37,16 @@ function getAvailableTableFilters(tableKey) {
}; };
} }
// Filters for the 'stock test' table
if (tableKey == 'stocktests') {
return {
result: {
type: 'bool',
title: "{% trans 'Test result' %}",
},
}
}
// Filters for the "Build" table // Filters for the "Build" table
if (tableKey == 'build') { if (tableKey == 'build') {
return { return {
@ -124,4 +132,3 @@ function getAvailableTableFilters(tableKey) {
// Finally, no matching key // Finally, no matching key
return {}; return {};
} }
</script>