Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2019-06-02 20:42:37 +10:00
commit c00b19bc5b
24 changed files with 325 additions and 121 deletions

View File

@ -445,9 +445,9 @@ class IndexView(TemplateView):
# TODO - Is there a less expensive way to get these from the database # TODO - Is there a less expensive way to get these from the database
context['to_order'] = [part for part in Part.objects.filter(purchaseable=True) if part.need_to_restock()] context['to_order'] = [part for part in Part.objects.filter(purchaseable=True) if part.need_to_restock()]
# Generate a list of buildable parts which have stock below their minimum values # Generate a list of assembly parts which have stock below their minimum values
# TODO - Is there a less expensive way to get these from the database # TODO - Is there a less expensive way to get these from the database
context['to_build'] = [part for part in Part.objects.filter(buildable=True) if part.need_to_restock()] context['to_build'] = [part for part in Part.objects.filter(assembly=True) if part.need_to_restock()]
return context return context

View File

@ -51,7 +51,7 @@ class Build(models.Model):
related_name='builds', related_name='builds',
limit_choices_to={ limit_choices_to={
'is_template': False, 'is_template': False,
'buildable': True, 'assembly': True,
'active': True 'active': True
}, },
help_text='Select part to build', help_text='Select part to build',

View File

@ -51,7 +51,7 @@ class CategoryList(generics.ListCreateAPIView):
filter_backends = [ filter_backends = [
DjangoFilterBackend, DjangoFilterBackend,
# filters.SearchFilter, filters.SearchFilter,
filters.OrderingFilter, filters.OrderingFilter,
] ]
@ -129,8 +129,8 @@ class PartList(generics.ListCreateAPIView):
filter_fields = [ filter_fields = [
'is_template', 'is_template',
'variant_of', 'variant_of',
'buildable', 'assembly',
'consumable', 'component',
'trackable', 'trackable',
'purchaseable', 'purchaseable',
'salable', 'salable',

View File

@ -57,5 +57,5 @@
fields: fields:
name: 'Bob' name: 'Bob'
description: 'Can we build it?' description: 'Can we build it?'
buildable: true assembly: true

View File

@ -92,17 +92,17 @@ class EditPartForm(HelperForm):
'category', 'category',
'name', 'name',
'IPN', 'IPN',
'is_template',
'variant_of',
'description', 'description',
'keywords', 'keywords',
'variant_of',
'is_template',
'URL', 'URL',
'default_location', 'default_location',
'default_supplier', 'default_supplier',
'units', 'units',
'minimum_stock', 'minimum_stock',
'buildable', 'assembly',
'consumable', 'component',
'trackable', 'trackable',
'purchaseable', 'purchaseable',
'salable', 'salable',

View File

@ -0,0 +1,42 @@
# Generated by Django 2.2 on 2019-06-02 09:44
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('part', '0006_auto_20190526_1215'),
]
operations = [
migrations.RemoveField(
model_name='part',
name='buildable',
),
migrations.RemoveField(
model_name='part',
name='consumable',
),
migrations.AddField(
model_name='part',
name='assembly',
field=models.BooleanField(default=False, help_text='Can this part be built from other parts?', verbose_name='Assembly'),
),
migrations.AddField(
model_name='part',
name='component',
field=models.BooleanField(default=True, help_text='Can this part be used to build other parts?', verbose_name='Component'),
),
migrations.AlterField(
model_name='bomitem',
name='part',
field=models.ForeignKey(help_text='Select parent part', limit_choices_to={'active': True, 'assembly': True}, on_delete=django.db.models.deletion.CASCADE, related_name='bom_items', to='part.Part'),
),
migrations.AlterField(
model_name='bomitem',
name='sub_part',
field=models.ForeignKey(help_text='Select part to be used in BOM', limit_choices_to={'active': True, 'component': True}, on_delete=django.db.models.deletion.CASCADE, related_name='used_in', to='part.Part'),
),
]

View File

@ -201,8 +201,8 @@ class Part(models.Model):
minimum_stock: Minimum preferred quantity to keep in stock minimum_stock: Minimum preferred quantity to keep in stock
units: Units of measure for this part (default='pcs') units: Units of measure for this part (default='pcs')
salable: Can this part be sold to customers? salable: Can this part be sold to customers?
buildable: Can this part be build from other parts? assembly: Can this part be build from other parts?
consumable: Can this part be used to make other parts? component: Can this part be used to make other parts?
purchaseable: Can this part be purchased from suppliers? purchaseable: Can this part be purchased from suppliers?
trackable: Trackable parts can have unique serial numbers assigned, etc, etc trackable: Trackable parts can have unique serial numbers assigned, etc, etc
active: Is this part active? Parts are deactivated instead of being deleted active: Is this part active? Parts are deactivated instead of being deleted
@ -248,6 +248,18 @@ class Part(models.Model):
else: else:
return static('/img/blank_image.png') return static('/img/blank_image.png')
def validate_unique(self, exclude=None):
super().validate_unique(exclude)
# Part name uniqueness should be case insensitive
try:
if Part.objects.filter(name__iexact=self.name).exclude(id=self.id).exists():
raise ValidationError({
"name": _("A part with this name already exists")
})
except Part.DoesNotExist:
pass
def clean(self): def clean(self):
""" Perform cleaning operations for the Part model """ """ Perform cleaning operations for the Part model """
@ -343,9 +355,9 @@ class Part(models.Model):
units = models.CharField(max_length=20, default="pcs", blank=True, help_text='Stock keeping units for this part') units = models.CharField(max_length=20, default="pcs", blank=True, help_text='Stock keeping units for this part')
buildable = models.BooleanField(default=False, help_text='Can this part be built from other parts?') assembly = models.BooleanField(default=False, verbose_name='Assembly', help_text='Can this part be built from other parts?')
consumable = models.BooleanField(default=True, help_text='Can this part be used to build other parts?') component = models.BooleanField(default=True, verbose_name='Component', help_text='Can this part be used to build other parts?')
trackable = models.BooleanField(default=False, help_text='Does this part have tracking for unique items?') trackable = models.BooleanField(default=False, help_text='Does this part have tracking for unique items?')
@ -858,7 +870,7 @@ class BomItem(models.Model):
part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='bom_items', part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='bom_items',
help_text='Select parent part', help_text='Select parent part',
limit_choices_to={ limit_choices_to={
'buildable': True, 'assembly': True,
'active': True, 'active': True,
}) })
@ -867,7 +879,7 @@ class BomItem(models.Model):
sub_part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='used_in', sub_part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='used_in',
help_text='Select part to be used in BOM', help_text='Select part to be used in BOM',
limit_choices_to={ limit_choices_to={
'consumable': True, 'component': True,
'active': True 'active': True
}) })

View File

@ -94,8 +94,8 @@ class PartSerializer(serializers.ModelSerializer):
# 'available_stock', # 'available_stock',
'units', 'units',
'trackable', 'trackable',
'buildable', 'assembly',
'consumable', 'component',
'trackable', 'trackable',
'salable', 'salable',
'active', 'active',

View File

@ -156,6 +156,7 @@
{% endif %} {% endif %}
}, },
buttons: ['#part-options'], buttons: ['#part-options'],
checkbox: true,
}, },
); );

View File

@ -90,30 +90,6 @@
<td><b>Units</b></td> <td><b>Units</b></td>
<td>{{ part.units }}</td> <td>{{ part.units }}</td>
</tr> </tr>
</table>
</div>
<div class='col-sm-6'>
<table class='table table-striped'>
<tr>
<td><b>Buildable</b></td>
<td>{% include "yesnolabel.html" with value=part.buildable %}</td>
</tr>
<tr>
<td><b>Consumable</b></td>
<td>{% include "yesnolabel.html" with value=part.consumable %}</td>
</tr>
<tr>
<td><b>Trackable</b></td>
<td>{% include "yesnolabel.html" with value=part.trackable %}</td>
</tr>
<tr>
<td><b>Purchaseable</b></td>
<td>{% include "yesnolabel.html" with value=part.purchaseable %}</td>
</tr>
<tr>
<td><b>Salable</b></td>
<td>{% include "yesnolabel.html" with value=part.salable %}</td>
</tr>
{% if part.minimum_stock > 0 %} {% if part.minimum_stock > 0 %}
<tr> <tr>
<td><b>Minimum Stock</b></td> <td><b>Minimum Stock</b></td>
@ -122,6 +98,40 @@
{% endif %} {% endif %}
</table> </table>
</div> </div>
<div class='col-sm-6'>
<table class='table table-striped'>
{% if part.assembly %}
<tr>
<td><b>Assembly</b></td>
<td><i>This part can be assembled from other parts</i></td>
</tr>
{% endif %}
{% if part.component %}
<tr>
<td><b>Component</b></td>
<td><i>This part can be used in assemblies</i></td>
</tr>
{% endif %}
{% if part.trackable %}
<tr>
<td><b>Trackable</b></td>
<td><i>Stock for this part will be tracked by (serial or batch)</i></td>
</tr>
{% endif %}
{% if part.purchaseable %}
<tr>
<td><b>Purchaseable</b></td>
<td><i>This part can be purchased from external suppliers</i></td>
</tr>
{% endif %}
{% if part.salable %}
<tr>
<td><b>Salable</b></td>
<td><i>This part can be sold to customers</i></td>
</tr>
{% endif %}
</table>
</div>
</div> </div>
{% if part.notes %} {% if part.notes %}

View File

@ -78,7 +78,7 @@
<td>In Stock</td> <td>In Stock</td>
<td>{{ part.total_stock }}</td> <td>{{ part.total_stock }}</td>
</tr> </tr>
{% if part.buildable %} {% if part.assembly %}
<tr> <tr>
<td>Can Build</td> <td>Can Build</td>
<td>{{ part.can_build }}</td> <td>{{ part.can_build }}</td>

View File

@ -15,13 +15,13 @@
<a href="{% url 'part-allocation' part.id %}">Allocated <span class="badge">{{ part.allocation_count }}</span></a> <a href="{% url 'part-allocation' part.id %}">Allocated <span class="badge">{{ part.allocation_count }}</span></a>
</li> </li>
{% endif %} {% endif %}
{% if part.buildable %} {% if part.assembly %}
<li{% ifequal tab 'bom' %} class="active"{% endifequal %}> <li{% ifequal tab 'bom' %} class="active"{% endifequal %}>
<a href="{% url 'part-bom' part.id %}">BOM<span class="badge{% if part.is_bom_valid == False %} badge-alert{% endif %}">{{ part.bom_count }}</span></a></li> <a href="{% url 'part-bom' part.id %}">BOM<span class="badge{% if part.is_bom_valid == False %} badge-alert{% endif %}">{{ part.bom_count }}</span></a></li>
<li{% ifequal tab 'build' %} class="active"{% endifequal %}> <li{% ifequal tab 'build' %} class="active"{% endifequal %}>
<a href="{% url 'part-build' part.id %}">Build<span class='badge'>{{ part.active_builds|length }}</span></a></li> <a href="{% url 'part-build' part.id %}">Build<span class='badge'>{{ part.active_builds|length }}</span></a></li>
{% endif %} {% endif %}
{% if part.consumable or part.used_in_count > 0 %} {% if part.component or part.used_in_count > 0 %}
<li{% ifequal tab 'used' %} class="active"{% endifequal %}> <li{% ifequal tab 'used' %} class="active"{% endifequal %}>
<a href="{% url 'part-used-in' part.id %}">Used In{% if part.used_in_count > 0 %}<span class="badge">{{ part.used_in_count }}</span>{% endif %}</a></li> <a href="{% url 'part-used-in' part.id %}">Used In{% if part.used_in_count > 0 %}<span class="badge">{{ part.used_in_count }}</span>{% endif %}</a></li>
{% endif %} {% endif %}

View File

@ -149,7 +149,7 @@ class PartAPITest(APITestCase):
response = self.client.post(url, data, format='json') response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.status_code, status.HTTP_201_CREATED)
# Now try to create a BomItem which points to a non-buildable part (should fail) # Now try to create a BomItem which points to a non-assembly part (should fail)
data['part'] = 3 data['part'] = 3
response = self.client.post(url, data, format='json') response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

View File

@ -124,6 +124,10 @@ function imageHoverIcon(url) {
* On mouseover, display a full-size version of the image * On mouseover, display a full-size version of the image
*/ */
if (!url) {
url = '/static/img/blank_image.png';
}
var html = ` var html = `
<a class='hover-icon'> <a class='hover-icon'>
<img class='hover-img-thumb' src='` + url + `'> <img class='hover-img-thumb' src='` + url + `'>

View File

@ -82,6 +82,7 @@ function loadPartTable(table, url, options={}) {
* - url: Base URL for API query * - url: Base URL for API query
* - options: object containing following (optional) fields * - options: object containing following (optional) fields
* allowInactive: If true, allow display of inactive parts * allowInactive: If true, allow display of inactive parts
* checkbox: Show the checkbox column
* query: extra query params for API request * query: extra query params for API request
* buttons: If provided, link buttons to selection status of this table * buttons: If provided, link buttons to selection status of this table
*/ */
@ -94,31 +95,23 @@ function loadPartTable(table, url, options={}) {
query.active = true; query.active = true;
} }
$(table).bootstrapTable({ var columns = [
url: url,
sortable: true,
search: true,
sortName: 'name',
method: 'get',
pagination: true,
pageSize: 25,
rememberOrder: true,
formatNoMatches: function() { return "No parts found"; },
queryParams: function(p) {
return query;
},
columns: [
{
checkbox: true,
title: 'Select',
searchable: false,
},
{ {
field: 'pk', field: 'pk',
title: 'ID', title: 'ID',
visible: false, visible: false,
}, }
{ ];
if (options.checkbox) {
columns.push({
checkbox: true,
title: 'Select',
searchable: false,
});
}
columns.push({
field: 'full_name', field: 'full_name',
title: 'Part', title: 'Part',
sortable: true, sortable: true,
@ -135,8 +128,9 @@ function loadPartTable(table, url, options={}) {
} }
return display; return display;
} }
}, });
{
columns.push({
sortable: true, sortable: true,
field: 'description', field: 'description',
title: 'Description', title: 'Description',
@ -148,8 +142,9 @@ function loadPartTable(table, url, options={}) {
return value; return value;
} }
}, });
{
columns.push({
sortable: true, sortable: true,
field: 'category_name', field: 'category_name',
title: 'Category', title: 'Category',
@ -161,8 +156,9 @@ function loadPartTable(table, url, options={}) {
return ''; return '';
} }
} }
}, });
{
columns.push({
field: 'total_stock', field: 'total_stock',
title: 'Stock', title: 'Stock',
searchable: false, searchable: false,
@ -175,8 +171,22 @@ function loadPartTable(table, url, options={}) {
return "<span class='label label-warning'>No Stock</span>"; return "<span class='label label-warning'>No Stock</span>";
} }
} }
} });
],
$(table).bootstrapTable({
url: url,
sortable: true,
search: true,
sortName: 'name',
method: 'get',
pagination: true,
pageSize: 25,
rememberOrder: true,
formatNoMatches: function() { return "No parts found"; },
queryParams: function(p) {
return query;
},
columns: columns,
}); });
if (options.buttons) { if (options.buttons) {

View File

@ -153,7 +153,10 @@ function loadStockTable(table, options) {
var text = renderLink(val, '/stock/item/' + row.pk + '/'); var text = renderLink(val, '/stock/item/' + row.pk + '/');
if (row.status_text != 'OK') {
text = text + "<span class='badge'>" + row.status_text + "</span>"; text = text + "<span class='badge'>" + row.status_text + "</span>";
}
return text; return text;
} }
}, },

View File

@ -236,6 +236,11 @@ class StockLocationList(generics.ListCreateAPIView):
'parent', 'parent',
] ]
search_fields = [
'name',
'description',
]
class StockList(generics.ListCreateAPIView): class StockList(generics.ListCreateAPIView):
""" API endpoint for list view of Stock objects """ API endpoint for list view of Stock objects
@ -306,6 +311,8 @@ class StockList(generics.ListCreateAPIView):
else: else:
item['location__path'] = None item['location__path'] = None
item['status_text'] = StockItem.ITEM_STATUS_CODES[item['status']]
return Response(data) return Response(data)
def get_queryset(self): def get_queryset(self):

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2 on 2019-06-02 09:44
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stock', '0004_auto_20190525_2356'),
]
operations = [
migrations.AlterField(
model_name='stockitem',
name='status',
field=models.PositiveIntegerField(choices=[(10, 'OK'), (50, 'Attention needed'), (55, 'Damaged'), (60, 'Destroyed'), (70, 'Lost')], default=10, validators=[django.core.validators.MinValueValidator(0)]),
),
]

View File

@ -260,12 +260,14 @@ class StockItem(models.Model):
ITEM_ATTENTION = 50 ITEM_ATTENTION = 50
ITEM_DAMAGED = 55 ITEM_DAMAGED = 55
ITEM_DESTROYED = 60 ITEM_DESTROYED = 60
ITEM_LOST = 70
ITEM_STATUS_CODES = { ITEM_STATUS_CODES = {
ITEM_OK: _("OK"), ITEM_OK: _("OK"),
ITEM_ATTENTION: _("Attention needed"), ITEM_ATTENTION: _("Attention needed"),
ITEM_DAMAGED: _("Damaged"), ITEM_DAMAGED: _("Damaged"),
ITEM_DESTROYED: _("Destroyed") ITEM_DESTROYED: _("Destroyed"),
ITEM_LOST: _("Lost")
} }
status = models.PositiveIntegerField( status = models.PositiveIntegerField(

View File

@ -17,10 +17,18 @@ InvenTree | Search Results
<br><br> <br><br>
<hr> <hr>
<div id='no-search-results'>
<h4><i>No results found</i></h4>
</div>
{% include "InvenTree/search_part_category.html" with collapse_id="categories" %}
{% include "InvenTree/search_parts.html" with collapse_id='parts' %} {% include "InvenTree/search_parts.html" with collapse_id='parts' %}
{% include "InvenTree/search_supplier_parts.html" with collapse_id='supplier_parts' %} {% include "InvenTree/search_supplier_parts.html" with collapse_id='supplier_parts' %}
{% include "InvenTree/search_stock_location.html" with collapse_id="locations" %}
{% endblock %} {% endblock %}
{% block js_load %} {% block js_load %}
@ -31,29 +39,86 @@ InvenTree | Search Results
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
$(".panel-group").hide();
function onSearchResults(table, output) { function onSearchResults(table, output) {
$(table).on('load-success.bs.table', function() { $(table).on('load-success.bs.table', function() {
var panel = $(output).closest('.panel-group');
var n = $(table).bootstrapTable('getData').length; var n = $(table).bootstrapTable('getData').length;
var text = ''; var text = '';
if (n == 0) { if (n == 0) {
text = '<i>No results</i>' text = '<i>No results</i>'
$(panel).hide();
} else { } else {
text = n + ' result'; text = n + ' result';
if (n > 1) { if (n > 1) {
text += 's'; text += 's';
} }
$(panel).show();
$("#no-search-results").hide();
} }
$(output).html(text); $(output).html(text);
}); });
} }
onSearchResults("#category-results-table", "#category-results-count");
onSearchResults("#location-results-table", "#location-results-count");
onSearchResults('#part-results-table', '#part-result-count'); onSearchResults('#part-results-table', '#part-result-count');
onSearchResults('#supplier-part-results-table', '#supplier-part-result-count'); onSearchResults('#supplier-part-results-table', '#supplier-part-result-count');
$("#category-results-table").bootstrapTable({
url: "{% url 'api-part-category-list' %}",
queryParams: {
search: "{{ query }}",
},
columns: [
{
field: 'name',
title: 'Name',
formatter: function(value, row, index, field) {
return renderLink(value, '/part/category/' + row.pk + '/');
},
},
{
field: 'description',
title: 'Description',
},
],
});
$("#location-results-table").bootstrapTable({
url: "{% url 'api-location-list' %}",
queryParams: {
search: "{{ query }}",
},
columns: [
{
field: 'name',
title: 'Name',
formatter: function(value, row, index, field) {
return renderLink(value, '/stock/location/' + row.pk + '/');
},
},
{
field: 'description',
title: 'Description',
},
],
});
loadPartTable("#part-results-table", loadPartTable("#part-results-table",
"{% url 'api-part-list' %}", "{% url 'api-part-list' %}",
{ {
@ -61,6 +126,7 @@ InvenTree | Search Results
search: "{{ query }}", search: "{{ query }}",
}, },
allowInactive: true, allowInactive: true,
checkbox: false,
} }
); );

View File

@ -0,0 +1,14 @@
{% extends "collapse.html" %}
{% block collapse_title %}
<h4>Part Categories</h4>
{% endblock %}
{% block collapse_heading %}
<h4><span id='category-results-count'>{% include "InvenTree/searching.html" %}</span></h4>
{% endblock %}
{% block collapse_content %}
<table class='table table-striped table-condensed' data-toolbar="#button-toolbar" id='category-results-table'>
</table>
{% endblock %}

View File

@ -0,0 +1,14 @@
{% extends "collapse.html" %}
{% block collapse_title %}
<h4>Stock Locations</h4>
{% endblock %}
{% block collapse_heading %}
<h4><span id='location-results-count'>{% include "InvenTree/searching.html" %}</span></h4>
{% endblock %}
{% block collapse_content %}
<table class='table table-striped table-condensed' data-toolbar="#button-toolbar" id='location-results-table'>
</table>
{% endblock %}

View File

@ -1 +1 @@
<span class='glyphicon glyphicon-refresh glyphicon-refresh-animate'></span> Searching... <span class='glyphicon glyphicon-refresh glyphicon-refresh-animate'></span> Searching

View File

@ -3,5 +3,5 @@
<div class="form-group"> <div class="form-group">
<input type="text" name='search' class="form-control" placeholder="Search"{% if query_text %} value="{{ query }}"{% endif %}> <input type="text" name='search' class="form-control" placeholder="Search"{% if query_text %} value="{{ query }}"{% endif %}>
</div> </div>
<button type="submit" id='search-submit' class="btn btn-default">Submit</button> <button type="submit" id='search-submit' class="btn btn-default">Search</button>
</form> </form>