Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2019-05-16 23:15:02 +10:00
commit 5d4ec2154b
26 changed files with 421 additions and 268 deletions

View File

@ -7,9 +7,56 @@ from django.utils.translation import gettext_lazy as _
def validate_part_name(value): def validate_part_name(value):
# Prevent some illegal characters in part names """ Prevent some illegal characters in part names.
for c in ['|', '#', '$']: """
for c in ['|', '#', '$', '{', '}']:
if c in str(value): if c in str(value):
raise ValidationError( raise ValidationError(
_('Invalid character in part name') _('Invalid character in part name')
) )
def validate_overage(value):
""" Validate that a BOM overage string is properly formatted.
An overage string can look like:
- An integer number ('1' / 3 / 4)
- A percentage ('5%' / '10 %')
"""
value = str(value).lower().strip()
# First look for a simple integer value
try:
i = int(value)
if i < 0:
raise ValidationError(_("Overage value must not be negative"))
# Looks like an integer!
return True
except ValueError:
pass
# Now look for a percentage value
if value.endswith('%'):
v = value[:-1].strip()
# Does it look like a number?
try:
f = float(v)
if f < 0:
raise ValidationError(_("Overage value must not be negative"))
elif f > 100:
raise ValidationError(_("Overage must not exceed 100%"))
return True
except ValueError:
pass
raise ValidationError(
_("Overage must be an integer value or a percentage")
)

View File

@ -59,7 +59,7 @@ class Build(models.Model):
take_from = models.ForeignKey('stock.StockLocation', on_delete=models.SET_NULL, take_from = models.ForeignKey('stock.StockLocation', on_delete=models.SET_NULL,
related_name='sourcing_builds', related_name='sourcing_builds',
null=True, blank=True, null=True, blank=True,
help_text='Select location to take stock from for this build (leave blank to take from any stock location' help_text='Select location to take stock from for this build (leave blank to take from any stock location)'
) )
quantity = models.PositiveIntegerField( quantity = models.PositiveIntegerField(
@ -261,7 +261,7 @@ class Build(models.Model):
try: try:
item = BomItem.objects.get(part=self.part.id, sub_part=part.id) item = BomItem.objects.get(part=self.part.id, sub_part=part.id)
return item.quantity * self.quantity return item.get_required_quantity(self.quantity)
except BomItem.DoesNotExist: except BomItem.DoesNotExist:
return 0 return 0

View File

@ -7,6 +7,7 @@ from __future__ import unicode_literals
import os import os
from django.apps import apps
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.conf import settings from django.conf import settings
@ -111,6 +112,18 @@ class Company(models.Model):
""" Return True if this company supplies any parts """ """ Return True if this company supplies any parts """
return self.part_count > 0 return self.part_count > 0
@property
def stock_items(self):
""" Return a list of all stock items supplied by this company """
stock = apps.get_model('stock', 'StockItem')
return stock.objects.filter(supplier_part__supplier=self.id).all()
@property
def stock_count(self):
""" Return the number of stock items supplied by this company """
stock = apps.get_model('stock', 'StockItem')
return stock.objects.filter(supplier_part__supplier=self.id).count()
class Contact(models.Model): class Contact(models.Model):
""" A Contact represents a person who works at a particular company. """ A Contact represents a person who works at a particular company.

View File

@ -25,7 +25,25 @@ class CompanySerializer(serializers.ModelSerializer):
""" Serializer for Company object (full detail) """ """ Serializer for Company object (full detail) """
url = serializers.CharField(source='get_absolute_url', read_only=True) url = serializers.CharField(source='get_absolute_url', read_only=True)
part_count = serializers.CharField(read_only=True)
class Meta: class Meta:
model = Company model = Company
fields = '__all__' fields = [
'id',
'url',
'name',
'description',
'website',
'name',
'phone',
'address',
'email',
'contact',
'URL',
'image',
'notes',
'is_customer',
'is_supplier',
'part_count'
]

View File

@ -72,6 +72,11 @@ InvenTree | Company - {{ company.name }}
{% endblock %} {% endblock %}
{% block js_load %}
{{ block.super }}
<script type='text/javascript' src="{% static 'script/inventree/stock.js' %}"></script>
{% endblock %}
{% block js_ready %} {% block js_ready %}
enableDragAndDrop( enableDragAndDrop(

View File

@ -33,6 +33,14 @@
supplier: {{ company.id }} supplier: {{ company.id }}
}, },
reload: true, reload: true,
secondary: [
{
field: 'part',
label: 'New Part',
title: 'Create New Part',
url: "{% url 'part-create' %}"
},
]
}); });
}); });

View File

@ -0,0 +1,26 @@
{% extends "company/company_base.html" %}
{% load static %}
{% block details %}
{% include "company/tabs.html" with tab='stock' %}
<h3>Supplier Stock</h3>
{% include "stock_table.html" %}
{% endblock %}
{% block js_ready %}
{{ block.super }}
loadStockTable($('#stock-table'), {
url: "{% url 'api-stock-list' %}",
params: {
supplier: {{ company.id }},
},
buttons: [
'#stock-options',
]
});
{% endblock %}

View File

@ -3,19 +3,19 @@
{% load static %} {% load static %}
{% block page_title %} {% block page_title %}
InvenTree | Company List InvenTree | Supplier List
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class='row'> <div class='row'>
<div class='col-sm-6'> <div class='col-sm-6'>
<h3>Company List</h3> <h3>Supplier List</h3>
</div> </div>
<div class='col-sm-6'> <div class='col-sm-6'>
<div class='container' id='active-build-toolbar' style='float: right;'> <div class='container' id='active-build-toolbar' style='float: right;'>
<div class='btn-group' style='float: right;'> <div class='btn-group' style='float: right;'>
<button type='button' class="btn btn-success" id='new-company'>New Company</button> <button type='button' class="btn btn-success" id='new-company' title='Add new supplier'>New Supplier</button>
</div> </div>
</div> </div>
</div> </div>
@ -54,7 +54,7 @@ InvenTree | Company List
}, },
{ {
field: 'name', field: 'name',
title: 'Company', title: 'Supplier',
sortable: true, sortable: true,
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
return imageHoverIcon(row.image) + renderLink(value, row.url); return imageHoverIcon(row.image) + renderLink(value, row.url);
@ -73,7 +73,14 @@ InvenTree | Company List
} }
return ''; return '';
} }
} },
{
field: 'part_count',
title: 'Parts',
formatter: function(value, row, index, field) {
return renderLink(value, row.url + 'parts/');
}
},
], ],
url: "{% url 'api-company-list' %}" url: "{% url 'api-company-list' %}"
}); });

View File

@ -6,6 +6,9 @@
<li{% if tab == 'parts' %} class='active'{% endif %}> <li{% if tab == 'parts' %} class='active'{% endif %}>
<a href="{% url 'company-detail-parts' company.id %}">Supplier Parts <span class='badge'>{{ company.part_count }}</span></a> <a href="{% url 'company-detail-parts' company.id %}">Supplier Parts <span class='badge'>{{ company.part_count }}</span></a>
</li> </li>
<li{% if tab == 'stock' %} class='active'{% endif %}>
<a href="{% url 'company-detail-stock' company.id %}">Stock <span class='badge'>{{ company.stock_count }}</a>
</li>
{% if 0 %} {% if 0 %}
<li{% if tab == 'po' %} class='active'{% endif %}> <li{% if tab == 'po' %} class='active'{% endif %}>
<a href="#">Purchase Orders</a> <a href="#">Purchase Orders</a>

View File

@ -16,6 +16,7 @@ company_detail_urls = [
# url(r'orders/?', views.CompanyDetail.as_view(template_name='company/orders.html'), name='company-detail-orders'), # url(r'orders/?', views.CompanyDetail.as_view(template_name='company/orders.html'), name='company-detail-orders'),
url(r'parts/?', views.CompanyDetail.as_view(template_name='company/detail_part.html'), name='company-detail-parts'), url(r'parts/?', views.CompanyDetail.as_view(template_name='company/detail_part.html'), name='company-detail-parts'),
url(r'stock/?', views.CompanyDetail.as_view(template_name='company/detail_stock.html'), name='company-detail-stock'),
url(r'thumbnail/?', views.CompanyImage.as_view(), name='company-image'), url(r'thumbnail/?', views.CompanyImage.as_view(), name='company-image'),

View File

@ -12,7 +12,6 @@ from rest_framework.response import Response
from rest_framework import filters from rest_framework import filters
from rest_framework import generics, permissions from rest_framework import generics, permissions
from django.db.models import Q
from django.conf.urls import url, include from django.conf.urls import url, include
from django.urls import reverse from django.urls import reverse
@ -109,20 +108,7 @@ class PartList(generics.ListCreateAPIView):
if cat_id: if cat_id:
try: try:
category = PartCategory.objects.get(pk=cat_id) category = PartCategory.objects.get(pk=cat_id)
parts_list = parts_list.filter(category__in=category.getUniqueChildren())
# Filter by the supplied category
flt = Q(category=cat_id)
if self.request.query_params.get('include_child_categories', None):
childs = category.getUniqueChildren()
for child in childs:
# Ignore the top-level category (already filtered)
if str(child) == str(cat_id):
continue
flt |= Q(category=child)
parts_list = parts_list.filter(flt)
except PartCategory.DoesNotExist: except PartCategory.DoesNotExist:
pass pass

View File

@ -133,6 +133,7 @@ class EditBomItemForm(HelperForm):
'part', 'part',
'sub_part', 'sub_part',
'quantity', 'quantity',
'overage',
'note' 'note'
] ]

View File

@ -0,0 +1,46 @@
# Generated by Django 2.2 on 2019-05-14 14:12
import InvenTree.validators
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('part', '0024_partcategory_default_keywords'),
]
operations = [
migrations.AddField(
model_name='bomitem',
name='overage',
field=models.CharField(blank=True, help_text='Estimated build wastage quantity (absolute or percentage)', max_length=24, validators=[InvenTree.validators.validate_overage]),
),
migrations.AlterField(
model_name='bomitem',
name='note',
field=models.CharField(blank=True, help_text='BOM item notes', max_length=100),
),
migrations.AlterField(
model_name='bomitem',
name='part',
field=models.ForeignKey(help_text='Select parent part', limit_choices_to={'active': True, 'buildable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='bom_items', to='part.Part'),
),
migrations.AlterField(
model_name='bomitem',
name='quantity',
field=models.PositiveIntegerField(default=1, help_text='BOM quantity for this BOM item', validators=[django.core.validators.MinValueValidator(0)]),
),
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, 'consumable': True}, on_delete=django.db.models.deletion.CASCADE, related_name='used_in', to='part.Part'),
),
migrations.AlterField(
model_name='supplierpart',
name='URL',
field=models.URLField(blank=True, help_text='URL for external supplier part link'),
),
]

View File

@ -300,6 +300,23 @@ class Part(models.Model):
# Default case - no default category found # Default case - no default category found
return None return None
def get_default_supplier(self):
""" Get the default supplier part for this part (may be None).
- If the part specifies a default_supplier, return that
- If there is only one supplier part available, return that
- Else, return None
"""
if self.default_supplier:
return self.default_suppliers
if self.supplier_count == 1:
return self.supplier_parts.first()
# Default to None if there are multiple suppliers to choose from
return None
default_supplier = models.ForeignKey('part.SupplierPart', default_supplier = models.ForeignKey('part.SupplierPart',
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
blank=True, null=True, blank=True, null=True,
@ -557,10 +574,11 @@ class Part(models.Model):
# Copy the part image # Copy the part image
if kwargs.get('image', True): if kwargs.get('image', True):
image_file = ContentFile(other.image.read()) if other.image:
image_file.name = rename_part_image(self, 'test.png') image_file = ContentFile(other.image.read())
image_file.name = rename_part_image(self, 'test.png')
self.image = image_file self.image = image_file
# Copy the BOM data # Copy the BOM data
if kwargs.get('bom', False): if kwargs.get('bom', False):
@ -661,6 +679,7 @@ class BomItem(models.Model):
part: Link to the parent part (the part that will be produced) part: Link to the parent part (the part that will be produced)
sub_part: Link to the child part (the part that will be consumed) sub_part: Link to the child part (the part that will be consumed)
quantity: Number of 'sub_parts' consumed to produce one 'part' quantity: Number of 'sub_parts' consumed to produce one 'part'
overage: Estimated losses for a Build. Can be expressed as absolute value (e.g. '7') or a percentage (e.g. '2%')
note: Note field for this BOM item note: Note field for this BOM item
""" """
@ -688,6 +707,10 @@ class BomItem(models.Model):
# Quantity required # Quantity required
quantity = models.PositiveIntegerField(default=1, validators=[MinValueValidator(0)], help_text='BOM quantity for this BOM item') quantity = models.PositiveIntegerField(default=1, validators=[MinValueValidator(0)], help_text='BOM quantity for this BOM item')
overage = models.CharField(max_length=24, blank=True, validators=[validators.validate_overage],
help_text='Estimated build wastage quantity (absolute or percentage)'
)
# Note attached to this BOM line item # Note attached to this BOM line item
note = models.CharField(max_length=100, blank=True, help_text='BOM item notes') note = models.CharField(max_length=100, blank=True, help_text='BOM item notes')
@ -721,6 +744,62 @@ class BomItem(models.Model):
child=self.sub_part.full_name, child=self.sub_part.full_name,
n=self.quantity) n=self.quantity)
def get_overage_quantity(self, quantity):
""" Calculate overage quantity
"""
# Most of the time overage string will be empty
if len(self.overage) == 0:
return 0
overage = str(self.overage).strip()
# Is the overage an integer value?
try:
ovg = int(overage)
if ovg < 0:
ovg = 0
return ovg
except ValueError:
pass
# Is the overage a percentage?
if overage.endswith('%'):
overage = overage[:-1].strip()
try:
percent = float(overage) / 100.0
if percent > 1:
percent = 1
if percent < 0:
percent = 0
return int(percent * quantity)
except ValueError:
pass
# Default = No overage
return 0
def get_required_quantity(self, build_quantity):
""" Calculate the required part quantity, based on the supplier build_quantity.
Includes overage estimate in the returned value.
Args:
build_quantity: Number of parts to build
Returns:
Quantity required for this build (including overage)
"""
# Base quantity requirement
base_quantity = self.quantity * build_quantity
return base_quantity + self.get_overage_quantity(base_quantity)
class SupplierPart(models.Model): class SupplierPart(models.Model):
""" Represents a unique part as provided by a Supplier """ Represents a unique part as provided by a Supplier

View File

@ -1,89 +0,0 @@
"""
TODO - Implement part parameters, and templates
See code below
"""
class PartParameterTemplate(models.Model):
""" A PartParameterTemplate pre-defines a parameter field,
ready to be copied for use with a given Part.
A PartParameterTemplate can be optionally associated with a PartCategory
"""
name = models.CharField(max_length=20, unique=True)
units = models.CharField(max_length=10, blank=True)
# Parameter format
PARAM_NUMERIC = 10
PARAM_TEXT = 20
PARAM_BOOL = 30
PARAM_TYPE_CODES = {
PARAM_NUMERIC: _("Numeric"),
PARAM_TEXT: _("Text"),
PARAM_BOOL: _("Bool")
}
format = models.PositiveIntegerField(
default=PARAM_NUMERIC,
choices=PARAM_TYPE_CODES.items(),
validators=[MinValueValidator(0)])
def __str__(self):
return "{name} ({units})".format(
name=self.name,
units=self.units)
class Meta:
verbose_name = "Parameter Template"
verbose_name_plural = "Parameter Templates"
class CategoryParameterLink(models.Model):
""" Links a PartParameterTemplate to a PartCategory
"""
category = models.ForeignKey(PartCategory, on_delete=models.CASCADE)
template = models.ForeignKey(PartParameterTemplate, on_delete=models.CASCADE)
def __str__(self):
return "{name} - {cat}".format(
name=self.template.name,
cat=self.category)
class Meta:
verbose_name = "Category Parameter"
verbose_name_plural = "Category Parameters"
unique_together = ('category', 'template')
class PartParameter(models.Model):
""" PartParameter is associated with a single part
"""
part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='parameters')
template = models.ForeignKey(PartParameterTemplate)
# Value data
value = models.CharField(max_length=50, blank=True)
min_value = models.CharField(max_length=50, blank=True)
max_value = models.CharField(max_length=50, blank=True)
def __str__(self):
return "{name} : {val}{units}".format(
name=self.template.name,
val=self.value,
units=self.template.units)
@property
def units(self):
return self.template.units
@property
def name(self):
return self.template.name
class Meta:
verbose_name = "Part Parameter"
verbose_name_plural = "Part Parameters"
unique_together = ('part', 'template')

View File

@ -104,8 +104,6 @@ class PartStarSerializer(InvenTreeModelSerializer):
class BomItemSerializer(InvenTreeModelSerializer): class BomItemSerializer(InvenTreeModelSerializer):
""" Serializer for BomItem object """ """ Serializer for BomItem object """
# url = serializers.CharField(source='get_absolute_url', read_only=True)
part_detail = PartBriefSerializer(source='part', many=False, read_only=True) part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
sub_part_detail = PartBriefSerializer(source='sub_part', many=False, read_only=True) sub_part_detail = PartBriefSerializer(source='sub_part', many=False, read_only=True)
@ -113,12 +111,12 @@ class BomItemSerializer(InvenTreeModelSerializer):
model = BomItem model = BomItem
fields = [ fields = [
'pk', 'pk',
# 'url',
'part', 'part',
'part_detail', 'part_detail',
'sub_part', 'sub_part',
'sub_part_detail', 'sub_part_detail',
'quantity', 'quantity',
'overage',
'note', 'note',
] ]

View File

@ -73,8 +73,22 @@
{% if category %} {% if category %}
data: { data: {
category: {{ category.id }} category: {{ category.id }}
} },
{% endif %} {% endif %}
secondary: [
{
field: 'default_location',
label: 'New Location',
title: 'Create new location',
url: "{% url 'stock-location-create' %}",
},
{
field: 'parent',
label: 'New Category',
title: 'Create new category',
url: "{% url 'category-create' %}",
},
]
}); });
}) })
@ -139,7 +153,6 @@
query: { query: {
{% if category %} {% if category %}
category: {{ category.id }}, category: {{ category.id }},
include_child_categories: true,
{% endif %} {% endif %}
}, },
buttons: ['#part-options'], buttons: ['#part-options'],

View File

@ -13,25 +13,7 @@
</div> </div>
<hr> <hr>
<div id='button-toolbar'> {% include "stock_table.html" %}
{% if part.active %}
<button class='btn btn-success' id='add-stock-item'>New Stock Item</button>
{% endif %}
<div id='opt-dropdown' class="dropdown" style='float: right;'>
<button id='stock-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">Options
<span class="caret"></span></button>
<ul class="dropdown-menu">
<li><a href='#' id='multi-item-take' title='Take items from stock'>Take items</a></li>
<li><a href='#' id='multi-item-give' title='Give items to stock'>Add items</a></li>
<li><a href="#" id='multi-item-stocktake' title='Stocktake selected stock items'>Stocktake</a></li>
<li><a href='#' id='multi-item-move' title='Move selected stock items'>Move items</a></li>
</ul>
</div>
</div>
<table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='stock-table'>
</table>
{% endblock %} {% endblock %}
@ -62,43 +44,4 @@
url: "{% url 'api-stock-list' %}", url: "{% url 'api-stock-list' %}",
}); });
function selectedStock() {
return $("#stock-table").bootstrapTable('getSelections');
}
$("#multi-item-move").click(function() {
var items = selectedStock();
moveStockItems(items,
{
success: function() {
$("#stock-table").bootstrapTable('refresh');
}
});
return false;
});
$("#multi-item-stocktake").click(function() {
updateStockItems({
action: 'stocktake'
});
return false;
});
$("#multi-item-take").click(function() {
updateStockItems({
action: 'remove',
});
return false;
});
$("#multi-item-give").click(function() {
updateStockItems({
action: 'add',
});
return false;
})
{% endblock %} {% endblock %}

View File

@ -105,13 +105,6 @@ class PartAPITest(APITestCase):
url = reverse('api-part-list') url = reverse('api-part-list')
data = {'category': 1} data = {'category': 1}
response = self.client.get(url, data, format='json')
# There should be 1 part in this category
self.assertEqual(len(response.data), 0)
data['include_child_categories'] = 1
# Now request to include child categories # Now request to include child categories
response = self.client.get(url, data, format='json') response = self.client.get(url, data, format='json')

View File

@ -113,6 +113,15 @@ function loadBomTable(table, options) {
title: 'Required', title: 'Required',
searchable: false, searchable: false,
sortable: true, sortable: true,
formatter: function(value, row, index, field) {
var text = value;
if (row.overage) {
text += "<small> (+" + row.overage + ") </small>";
}
return text;
}
} }
); );

View File

@ -43,6 +43,7 @@ function updateStock(items, options={}) {
html += '<th>Item</th>'; html += '<th>Item</th>';
html += '<th>Location</th>'; html += '<th>Location</th>';
html += '<th>Quantity</th>'; html += '<th>Quantity</th>';
html += '<th>' + options.action + '</th>';
html += '</thead><tbody>'; html += '</thead><tbody>';
@ -71,6 +72,9 @@ function updateStock(items, options={}) {
} else { } else {
html += '<td><i>No location set</i></td>'; html += '<td><i>No location set</i></td>';
} }
html += '<td>' + item.quantity + '</td>';
html += "<td><input class='form-control' "; html += "<td><input class='form-control' ";
html += "value='" + vCur + "' "; html += "value='" + vCur + "' ";
html += "min='" + vMin + "' "; html += "min='" + vMin + "' ";
@ -87,8 +91,18 @@ function updateStock(items, options={}) {
html += '</tbody></table>'; html += '</tbody></table>';
html += "<hr><input type='text' id='stocktake-notes' placeholder='Notes'/>"; html += "<hr><input type='text' id='stocktake-notes' placeholder='Notes'/>";
html += "<p class='help-inline' id='note-warning'><strong>Note field must be filled</strong></p>";
html += `
<hr>
<div class='control-group'>
<label class='checkbox'>
<input type='checkbox' id='stocktake-confirm' placeholder='Confirm'/>
Confirm Stocktake
</label>
<p class='help-inline' id='confirm-warning'><strong>Confirm stock count</strong></p>
</div>`;
html += "<p class='warning-msg' id='note-warning'><i>Note field must be filled</i></p>";
var title = ''; var title = '';
@ -109,6 +123,7 @@ function updateStock(items, options={}) {
}); });
$(modal).find('#note-warning').hide(); $(modal).find('#note-warning').hide();
$(modal).find('#confirm-warning').hide();
modalEnable(modal, true); modalEnable(modal, true);
@ -116,13 +131,23 @@ function updateStock(items, options={}) {
var stocktake = []; var stocktake = [];
var notes = $(modal).find('#stocktake-notes').val(); var notes = $(modal).find('#stocktake-notes').val();
var confirm = $(modal).find('#stocktake-confirm').is(':checked');
var valid = true;
if (!notes) { if (!notes) {
$(modal).find('#note-warning').show(); $(modal).find('#note-warning').show();
return false; valid = false;
} }
var valid = true; if (!confirm) {
$(modal).find('#confirm-warning').show();
valid = false;
}
if (!valid) {
return false;
}
// Form stocktake data // Form stocktake data
for (idx = 0; idx < items.length; idx++) { for (idx = 0; idx < items.length; idx++) {
@ -413,6 +438,42 @@ function loadStockTable(table, options) {
if (options.buttons) { if (options.buttons) {
linkButtonsToSelection(table, options.buttons); linkButtonsToSelection(table, options.buttons);
} }
// Automatically link button callbacks
$('#multi-item-stocktake').click(function() {
updateStockItems({
action: 'stocktake',
});
return false;
});
$('#multi-item-remove').click(function() {
updateStockItems({
action: 'remove',
});
return false;
});
$('#multi-item-add').click(function() {
updateStockItems({
action: 'add',
});
return false;
});
$("#multi-item-move").click(function() {
var items = $("#stock-table").bootstrapTable('getSelections');
moveStockItems(items,
{
success: function() {
$("#stock-table").bootstrapTable('refresh');
}
});
return false;
});
} }

View File

@ -7,11 +7,12 @@ from django_filters import NumberFilter
from django.conf.urls import url, include from django.conf.urls import url, include
from django.urls import reverse from django.urls import reverse
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 part.models import PartCategory
from .serializers import StockItemSerializer, StockQuantitySerializer from .serializers import StockItemSerializer, StockQuantitySerializer
from .serializers import LocationSerializer from .serializers import LocationSerializer
from .serializers import StockTrackingSerializer from .serializers import StockTrackingSerializer
@ -237,16 +238,20 @@ class StockList(generics.ListCreateAPIView):
- GET: Return a list of all StockItem objects (with optional query filters) - GET: Return a list of all StockItem objects (with optional query filters)
- POST: Create a new StockItem - POST: Create a new StockItem
Additional query parameters are available:
- location: Filter stock by location
- category: Filter by parts belonging to a certain category
- supplier: Filter by supplier
""" """
def get_queryset(self): def get_queryset(self):
""" """
If the query includes a particular location, If the query includes a particular location,
we may wish to also request stock items from all child locations. we may wish to also request stock items from all child locations.
This is set by the optional param 'include_child_categories'
""" """
# Does the client wish to filter by category? # Does the client wish to filter by stock location?
loc_id = self.request.query_params.get('location', None) loc_id = self.request.query_params.get('location', None)
# Start with all objects # Start with all objects
@ -255,23 +260,28 @@ class StockList(generics.ListCreateAPIView):
if loc_id: if loc_id:
try: try:
location = StockLocation.objects.get(pk=loc_id) location = StockLocation.objects.get(pk=loc_id)
stock_list = stock_list.filter(location__in=location.getUniqueChildren())
# Filter by the supplied category
flt = Q(location=loc_id)
if self.request.query_params.get('include_child_locations', None):
childs = location.getUniqueChildren()
for child in childs:
# Ignore the top-level category (already filtered!)
if str(child) == str(loc_id):
continue
flt |= Q(location=child)
stock_list = stock_list.filter(flt)
except StockLocation.DoesNotExist: except StockLocation.DoesNotExist:
pass pass
# Does the client wish to filter by part category?
cat_id = self.request.query_params.get('category', None)
if cat_id:
try:
category = PartCategory.objects.get(pk=cat_id)
stock_list = stock_list.filter(part__category__in=category.getUniqueChildren())
except PartCategory.DoesNotExist:
pass
# Filter by supplier
supplier_id = self.request.query_params.get('supplier', None)
if supplier_id:
stock_list = stock_list.filter(supplier_part__supplier=supplier_id)
return stock_list return stock_list
serializer_class = StockItemSerializer serializer_class = StockItemSerializer

View File

@ -44,24 +44,7 @@
<hr> <hr>
<div id='button-toolbar'> {% include "stock_table.html" %}
<div class='button-toolbar container-fluid' style='float: right;'>
<button class="btn btn-success" id='item-create'>New Stock Item</button>
<div class="dropdown" style='float: right;'>
<button id='stock-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">Options<span class="caret"></span></button>
<ul class="dropdown-menu">
<li><a href="#" id='multi-item-add' title='Add to selected stock items'>Add stock</a></li>
<li><a href="#" id='multi-item-remove' title='Remove from selected stock items'>Remove stock</a></li>
<li><a href="#" id='multi-item-stocktake' title='Stocktake selected stock items'>Stocktake</a></li>
<li><a href='#' id='multi-item-move' title='Move selected stock items'>Move</a></li>
</ul>
</div>
</div>
</div>
<table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='stock-table'>
</table>
{% include 'modals.html' %} {% include 'modals.html' %}
@ -80,7 +63,15 @@
location: {{ location.id }} location: {{ location.id }}
{% endif %} {% endif %}
}, },
follow: true follow: true,
secondary: [
{
field: 'parent',
label: 'New Location',
title: 'Create new location',
url: "{% url 'stock-location-create' %}",
},
]
}); });
return false; return false;
}); });
@ -142,45 +133,6 @@
return false; return false;
}); });
function selectedStock() {
return $("#stock-table").bootstrapTable('getSelections');
}
$("#multi-item-move").click(function() {
var items = selectedStock();
moveStockItems(items,
{
success: function() {
$("#stock-table").bootstrapTable('refresh');
}
});
return false;
});
$('#multi-item-stocktake').click(function() {
updateStockItems({
action: 'stocktake',
});
return false;
});
$('#multi-item-remove').click(function() {
updateStockItems({
action: 'remove',
});
return false;
});
$('#multi-item-add').click(function() {
updateStockItems({
action: 'add',
});
return false;
});
loadStockTable($("#stock-table"), { loadStockTable($("#stock-table"), {
buttons: [ buttons: [
'#stock-options', '#stock-options',
@ -188,7 +140,6 @@
params: { params: {
{% if location %} {% if location %}
location: {{ location.id }}, location: {{ location.id }},
include_child_locations: true,
{% endif %} {% endif %}
}, },
url: "{% url 'api-stock-list' %}", url: "{% url 'api-stock-list' %}",

View File

@ -1,5 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %}
{% block page_title %} {% block page_title %}
InvenTree | Search Results InvenTree | Search Results
{% endblock %} {% endblock %}
@ -19,6 +21,11 @@ InvenTree | Search Results
{% endblock %} {% endblock %}
{% block js_load %}
{{ block.super }}
<script type='text/javascript' src="{% static 'script/inventree/part.js' %}"></script>
{% endblock %}
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}

View File

@ -9,7 +9,7 @@
<li><a href="{% url 'part-index' %}">Parts</a></li> <li><a href="{% url 'part-index' %}">Parts</a></li>
<li><a href="{% url 'stock-index' %}">Stock</a></li> <li><a href="{% url 'stock-index' %}">Stock</a></li>
<li><a href="{% url 'build-index' %}">Build</a></li> <li><a href="{% url 'build-index' %}">Build</a></li>
<li><a href="{% url 'company-index' %}">Companies</a></li> <li><a href="{% url 'company-index' %}">Suppliers</a></li>
</ul> </ul>
<ul class="nav navbar-nav navbar-right"> <ul class="nav navbar-nav navbar-right">
{% include "search_form.html" %} {% include "search_form.html" %}

View File

@ -0,0 +1,17 @@
<div id='button-toolbar'>
<div class='button-toolbar container-fluid' style='float: right;'>
<button class="btn btn-success" id='item-create'>New Stock Item</button>
<div class="dropdown" style='float: right;'>
<button id='stock-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">Options<span class="caret"></span></button>
<ul class="dropdown-menu">
<li><a href="#" id='multi-item-add' title='Add to selected stock items'>Add stock</a></li>
<li><a href="#" id='multi-item-remove' title='Remove from selected stock items'>Remove stock</a></li>
<li><a href="#" id='multi-item-stocktake' title='Stocktake selected stock items'>Stocktake</a></li>
<li><a href='#' id='multi-item-move' title='Move selected stock items'>Move</a></li>
</ul>
</div>
</div>
</div>
<table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='stock-table'>
</table>