Merge remote-tracking branch 'upstream/master'

This commit is contained in:
Oliver Walters 2020-10-05 19:50:45 +11:00
commit 8f6c26c7d3
49 changed files with 8790 additions and 1615 deletions

View File

@ -138,6 +138,7 @@ INSTALLED_APPS = [
'part.apps.PartConfig', 'part.apps.PartConfig',
'report.apps.ReportConfig', 'report.apps.ReportConfig',
'stock.apps.StockConfig', 'stock.apps.StockConfig',
'users.apps.UsersConfig',
# Third part add-ons # Third part add-ons
'django_filters', # Extended filter functionality 'django_filters', # Extended filter functionality
@ -153,6 +154,7 @@ INSTALLED_APPS = [
'markdownx', # Markdown editing 'markdownx', # Markdown editing
'markdownify', # Markdown template rendering 'markdownify', # Markdown template rendering
'django_tex', # LaTeX output 'django_tex', # LaTeX output
'django_admin_shell', # Python shell for the admin interface
] ]
LOGGING = { LOGGING = {

View File

@ -0,0 +1,13 @@
@charset "UTF-8";
/**
* @author: Dennis Hernández
* @webSite: http://djhvscf.github.io/Blog
* @version: v2.1.1
*/
.no-filter-control {
height: 34px;
}
.filter-control {
margin: 0 2px 2px 2px;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -105,9 +105,14 @@ function makeProgressBar(value, maximum, opts) {
var options = opts || {}; var options = opts || {};
value = parseFloat(value); value = parseFloat(value);
maximum = parseFloat(maximum);
var percent = parseInt(value / maximum * 100); var percent = 100;
// Prevent div-by-zero or null value
if (maximum && maximum > 0) {
maximum = parseFloat(maximum);
percent = parseInt(value / maximum * 100);
}
if (percent > 100) { if (percent > 100) {
percent = 100; percent = 100;
@ -115,18 +120,28 @@ function makeProgressBar(value, maximum, opts) {
var extraclass = ''; var extraclass = '';
if (value > maximum) { if (maximum) {
// TODO - Special color?
}
else if (value > maximum) {
extraclass='progress-bar-over'; extraclass='progress-bar-over';
} else if (value < maximum) { } else if (value < maximum) {
extraclass = 'progress-bar-under'; extraclass = 'progress-bar-under';
} }
var text = value;
if (maximum) {
text += ' / ';
text += maximum;
}
var id = options.id || 'progress-bar'; var id = options.id || 'progress-bar';
return ` return `
<div id='${id}' class='progress'> <div id='${id}' class='progress'>
<div class='progress-bar ${extraclass}' role='progressbar' aria-valuenow='${percent}' aria-valuemin='0' aria-valuemax='100' style='width:${percent}%'></div> <div class='progress-bar ${extraclass}' role='progressbar' aria-valuenow='${percent}' aria-valuemin='0' aria-valuemax='100' style='width:${percent}%'></div>
<div class='progress-value'>${value} / ${maximum}</div> <div class='progress-value'>${text}</div>
</div> </div>
`; `;
} }

View File

@ -109,10 +109,20 @@ $.fn.inventreeTable = function(options) {
options.pagination = true; options.pagination = true;
options.pageSize = inventreeLoad(varName, 25); options.pageSize = inventreeLoad(varName, 25);
options.pageList = [25, 50, 100, 250, 'all']; options.pageList = [25, 50, 100, 250, 'all'];
options.rememberOrder = true; options.rememberOrder = true;
options.sortable = true;
options.search = true; if (options.sortable == null) {
options.showColumns = true; options.sortable = true;
}
if (options.search == null) {
options.search = true;
}
if (options.showColumns == null) {
options.showColumns = true;
}
// Callback to save pagination data // Callback to save pagination data
options.onPageChange = function(number, size) { options.onPageChange = function(number, size) {

View File

@ -117,6 +117,7 @@ urlpatterns = [
url(r'^edit-user/', EditUserView.as_view(), name='edit-user'), url(r'^edit-user/', EditUserView.as_view(), name='edit-user'),
url(r'^set-password/', SetPasswordView.as_view(), name='set-password'), url(r'^set-password/', SetPasswordView.as_view(), name='set-password'),
url(r'^admin/shell/', include('django_admin_shell.urls')),
url(r'^admin/', admin.site.urls, name='inventree-admin'), url(r'^admin/', admin.site.urls, name='inventree-admin'),
url(r'^qr_code/', include(qr_code_urls, namespace='qr_code')), url(r'^qr_code/', include(qr_code_urls, namespace='qr_code')),

View File

@ -38,4 +38,5 @@ class CompanyConfig(AppConfig):
company.image = None company.image = None
company.save() company.save()
except (OperationalError, ProgrammingError): except (OperationalError, ProgrammingError):
print("Could not generate Company thumbnails") # Getting here probably meant the database was in test mode
pass

View File

@ -24,7 +24,6 @@ def reverse_association(apps, schema_editor):
# Exit if there are no SupplierPart objects # Exit if there are no SupplierPart objects
# This crucial otherwise the unit test suite fails! # This crucial otherwise the unit test suite fails!
if SupplierPart.objects.count() == 0: if SupplierPart.objects.count() == 0:
print("No SupplierPart objects - skipping")
return return
print("Reversing migration for manufacturer association") print("Reversing migration for manufacturer association")
@ -105,7 +104,6 @@ def associate_manufacturers(apps, schema_editor):
# Exit if there are no SupplierPart objects # Exit if there are no SupplierPart objects
# This crucial otherwise the unit test suite fails! # This crucial otherwise the unit test suite fails!
if SupplierPart.objects.count() == 0: if SupplierPart.objects.count() == 0:
print("No SupplierPart objects - skipping")
return return
# Link a 'manufacturer_name' to a 'Company' # Link a 'manufacturer_name' to a 'Company'

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -765,18 +765,35 @@ class BomList(generics.ListCreateAPIView):
queryset = super().filter_queryset(queryset) queryset = super().filter_queryset(queryset)
params = self.request.query_params
# Filter by "optional" status?
optional = params.get('optional', None)
if optional is not None:
optional = str2bool(optional)
queryset = queryset.filter(optional=optional)
# Filter by part? # Filter by part?
part = self.request.query_params.get('part', None) part = params.get('part', None)
if part is not None: if part is not None:
queryset = queryset.filter(part=part) queryset = queryset.filter(part=part)
# Filter by sub-part? # Filter by sub-part?
sub_part = self.request.query_params.get('sub_part', None) sub_part = params.get('sub_part', None)
if sub_part is not None: if sub_part is not None:
queryset = queryset.filter(sub_part=sub_part) queryset = queryset.filter(sub_part=sub_part)
# Filter by "trackable" status of the sub-part
trackable = params.get('trackable', None)
if trackable is not None:
trackable = str2bool(trackable)
queryset = queryset.filter(sub_part__trackable=trackable)
return queryset return queryset
permission_classes = [ permission_classes = [

View File

@ -37,4 +37,4 @@ class PartConfig(AppConfig):
part.image = None part.image = None
part.save() part.save()
except (OperationalError, ProgrammingError): except (OperationalError, ProgrammingError):
print("Could not generate Part thumbnails") pass

View File

@ -231,7 +231,8 @@ class EditBomItemForm(HelperForm):
'quantity', 'quantity',
'reference', 'reference',
'overage', 'overage',
'note' 'note',
'optional',
] ]
# Prevent editing of the part associated with this BomItem # Prevent editing of the part associated with this BomItem

View File

@ -0,0 +1,18 @@
# Generated by Django 3.0.7 on 2020-10-04 13:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0050_auto_20200917_2315'),
]
operations = [
migrations.AddField(
model_name='bomitem',
name='optional',
field=models.BooleanField(default=False, help_text='This BOM item is optional'),
),
]

View File

@ -111,6 +111,58 @@ class PartCategory(InvenTreeTree):
""" True if there are any parts in this category """ """ True if there are any parts in this category """
return self.partcount() > 0 return self.partcount() > 0
def prefetch_parts_parameters(self, cascade=True):
""" Prefectch parts parameters """
return self.get_parts(cascade=cascade).prefetch_related('parameters', 'parameters__template').all()
def get_unique_parameters(self, cascade=True, prefetch=None):
""" Get all unique parameter names for all parts from this category """
unique_parameters_names = []
if prefetch:
parts = prefetch
else:
parts = self.prefetch_parts_parameters(cascade=cascade)
for part in parts:
for parameter in part.parameters.all():
parameter_name = parameter.template.name
if parameter_name not in unique_parameters_names:
unique_parameters_names.append(parameter_name)
return sorted(unique_parameters_names)
def get_parts_parameters(self, cascade=True, prefetch=None):
""" Get all parameter names and values for all parts from this category """
category_parameters = []
if prefetch:
parts = prefetch
else:
parts = self.prefetch_parts_parameters(cascade=cascade)
for part in parts:
part_parameters = {
'pk': part.pk,
'name': part.name,
'description': part.description,
}
# Add IPN only if it exists
if part.IPN:
part_parameters['IPN'] = part.IPN
for parameter in part.parameters.all():
parameter_name = parameter.template.name
parameter_value = parameter.data
part_parameters[parameter_name] = parameter_value
category_parameters.append(part_parameters)
return category_parameters
@receiver(pre_delete, sender=PartCategory, dispatch_uid='partcategory_delete_log') @receiver(pre_delete, sender=PartCategory, dispatch_uid='partcategory_delete_log')
def before_delete_part_category(sender, instance, using, **kwargs): def before_delete_part_category(sender, instance, using, **kwargs):
@ -382,7 +434,7 @@ class Part(MPTTModel):
return _('Next available serial numbers are') + ' ' + text return _('Next available serial numbers are') + ' ' + text
else: else:
text = str(latest) text = str(latest + 1)
return _('Next available serial number is') + ' ' + text return _('Next available serial number is') + ' ' + text
@ -732,12 +784,13 @@ class Part(MPTTModel):
""" Return the current number of parts currently being built """ Return the current number of parts currently being built
""" """
quantity = self.active_builds.aggregate(quantity=Sum('quantity'))['quantity'] stock_items = self.stock_items.filter(is_building=True)
if quantity is None: query = stock_items.aggregate(
quantity = 0 quantity=Coalesce(Sum('quantity'), Decimal(0))
)
return quantity return query['quantity']
def build_order_allocations(self): def build_order_allocations(self):
""" """
@ -1447,6 +1500,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'
optional: Boolean field describing if this BomItem is optional
reference: BOM reference field (e.g. part designators) reference: BOM reference field (e.g. part designators)
overage: Estimated losses for a Build. Can be expressed as absolute value (e.g. '7') or a percentage (e.g. '2%') 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
@ -1480,6 +1534,8 @@ class BomItem(models.Model):
# Quantity required # Quantity required
quantity = models.DecimalField(default=1.0, max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], help_text=_('BOM quantity for this BOM item')) quantity = models.DecimalField(default=1.0, max_digits=15, decimal_places=5, validators=[MinValueValidator(0)], help_text=_('BOM quantity for this BOM item'))
optional = models.BooleanField(default=False, help_text=_("This BOM item is optional"))
overage = models.CharField(max_length=24, blank=True, validators=[validators.validate_overage], overage = models.CharField(max_length=24, blank=True, validators=[validators.validate_overage],
help_text=_('Estimated build wastage quantity (absolute or percentage)') help_text=_('Estimated build wastage quantity (absolute or percentage)')
) )

View File

@ -403,6 +403,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
'quantity', 'quantity',
'reference', 'reference',
'price_range', 'price_range',
'optional',
'overage', 'overage',
'note', 'note',
'validated', 'validated',

View File

@ -120,8 +120,11 @@
</div> </div>
</div> </div>
{% block category_tables %}
<table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='part-table'> <table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='part-table'>
</table> </table>
{% endblock category_tables %}
{% endblock %} {% endblock %}
{% block js_load %} {% block js_load %}

View File

@ -0,0 +1,31 @@
{% extends "part/category.html" %}
{% load static %}
{% load i18n %}
{% block category_tables %}
{% include 'part/category_tabs.html' with tab='parametric-table' %}
<table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='parametric-part-table'>
</table>
{% endblock %}
{% block js_ready %}
{{ block.super }}
/* Hide Button Toolbar */
window.onload = function hideButtonToolbar() {
var toolbar = document.getElementById("button-toolbar");
toolbar.style.display = "none";
};
loadParametricPartTable(
"#parametric-part-table",
{
headers: {{ headers|safe }},
data: {{ parameters|safe }},
}
);
{% endblock %}

View File

@ -0,0 +1,12 @@
{% extends "part/category.html" %}
{% load static %}
{% load i18n %}
{% block category_tables %}
{% include 'part/category_tabs.html' with tab='part-list' %}
<table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='part-table'>
</table>
{% endblock %}

View File

@ -0,0 +1,11 @@
{% load i18n %}
{% load inventree_extras %}
<ul class="nav nav-tabs">
<li{% ifequal tab 'part-list' %} class="active"{% endifequal %}>
<a href="{% url 'category-detail' category.id %}">{% trans "Parts" %} <span class="badge">{% decimal part_count %}</span></a>
</li>
<li{% ifequal tab 'parametric-table' %} class='active'{% endifequal %}>
<a href="{% url 'category-parametric' category.id %}">{% trans "Parametric Table" %}</a>
</li>
</ul>

View File

@ -1,7 +1,7 @@
from django.test import TestCase from django.test import TestCase
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from .models import Part, PartCategory from .models import Part, PartCategory, PartParameter, PartParameterTemplate
class CategoryTest(TestCase): class CategoryTest(TestCase):
@ -15,6 +15,7 @@ class CategoryTest(TestCase):
'category', 'category',
'part', 'part',
'location', 'location',
'params',
] ]
def setUp(self): def setUp(self):
@ -94,6 +95,31 @@ class CategoryTest(TestCase):
self.assertEqual(self.electronics.item_count, self.electronics.partcount()) self.assertEqual(self.electronics.item_count, self.electronics.partcount())
def test_parameters(self):
""" Test that the Category parameters are correctly fetched """
# Check number of SQL queries to iterate other parameters
with self.assertNumQueries(3):
# Prefetch: 3 queries (parts, parameters and parameters_template)
fasteners = self.fasteners.prefetch_parts_parameters()
# Iterate through all parts and parameters
for fastener in fasteners:
self.assertIsInstance(fastener, Part)
for parameter in fastener.parameters.all():
self.assertIsInstance(parameter, PartParameter)
self.assertIsInstance(parameter.template, PartParameterTemplate)
# Test number of unique parameters
self.assertEqual(len(self.fasteners.get_unique_parameters(prefetch=fasteners)), 1)
# Test number of parameters found for each part
parts_parameters = self.fasteners.get_parts_parameters(prefetch=fasteners)
part_infos = ['pk', 'name', 'description']
for part_parameter in parts_parameters:
# Remove part informations
for item in part_infos:
part_parameter.pop(item)
self.assertEqual(len(part_parameter), 1)
def test_invalid_name(self): def test_invalid_name(self):
# Test that an illegal character is prohibited in a category name # Test that an illegal character is prohibited in a category name

View File

@ -77,7 +77,8 @@ part_category_urls = [
url(r'^edit/?', views.CategoryEdit.as_view(), name='category-edit'), url(r'^edit/?', views.CategoryEdit.as_view(), name='category-edit'),
url(r'^delete/?', views.CategoryDelete.as_view(), name='category-delete'), url(r'^delete/?', views.CategoryDelete.as_view(), name='category-delete'),
url('^.*$', views.CategoryDetail.as_view(), name='category-detail'), url(r'^parametric/?', views.CategoryParametric.as_view(), name='category-parametric'),
url(r'^.*$', views.CategoryDetail.as_view(), name='category-detail'),
] ]
part_bom_urls = [ part_bom_urls = [

View File

@ -1872,10 +1872,51 @@ class PartParameterDelete(AjaxDeleteView):
class CategoryDetail(DetailView): class CategoryDetail(DetailView):
""" Detail view for PartCategory """ """ Detail view for PartCategory """
model = PartCategory model = PartCategory
context_object_name = 'category' context_object_name = 'category'
queryset = PartCategory.objects.all().prefetch_related('children') queryset = PartCategory.objects.all().prefetch_related('children')
template_name = 'part/category.html' template_name = 'part/category_partlist.html'
def get_context_data(self, **kwargs):
context = super(CategoryDetail, self).get_context_data(**kwargs).copy()
try:
context['part_count'] = kwargs['object'].partcount()
except KeyError:
context['part_count'] = 0
return context
class CategoryParametric(CategoryDetail):
""" Parametric view for PartCategory """
template_name = 'part/category_parametric.html'
def get_context_data(self, **kwargs):
context = super(CategoryParametric, self).get_context_data(**kwargs).copy()
# Get current category
category = kwargs.get('object', None)
if category:
cascade = kwargs.get('cascade', True)
# Prefetch parts parameters
parts_parameters = category.prefetch_parts_parameters(cascade=cascade)
# Get table headers (unique parameters names)
context['headers'] = category.get_unique_parameters(cascade=cascade,
prefetch=parts_parameters)
# Insert part information
context['headers'].insert(0, 'description')
context['headers'].insert(0, 'part')
# Get parameters data
context['parameters'] = category.get_parts_parameters(cascade=cascade,
prefetch=parts_parameters)
return context
class CategoryEdit(AjaxUpdateView): class CategoryEdit(AjaxUpdateView):

View File

@ -8,6 +8,8 @@ from __future__ import unicode_literals
from django import forms from django import forms
from django.forms.utils import ErrorDict from django.forms.utils import ErrorDict
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.core.validators import MinValueValidator
from django.core.exceptions import ValidationError
from mptt.fields import TreeNodeChoiceField from mptt.fields import TreeNodeChoiceField
@ -17,6 +19,8 @@ from InvenTree.fields import RoundingDecimalFormField
from report.models import TestReport from report.models import TestReport
from part.models import Part
from .models import StockLocation, StockItem, StockItemTracking from .models import StockLocation, StockItem, StockItemTracking
from .models import StockItemAttachment from .models import StockItemAttachment
from .models import StockItemTestResult from .models import StockItemTestResult
@ -271,6 +275,59 @@ class ExportOptionsForm(HelperForm):
self.fields['file_format'].choices = self.get_format_choices() self.fields['file_format'].choices = self.get_format_choices()
class InstallStockForm(HelperForm):
"""
Form for manually installing a stock item into another stock item
"""
part = forms.ModelChoiceField(
queryset=Part.objects.all(),
widget=forms.HiddenInput()
)
stock_item = forms.ModelChoiceField(
required=True,
queryset=StockItem.objects.filter(StockItem.IN_STOCK_FILTER),
help_text=_('Stock item to install')
)
quantity_to_install = RoundingDecimalFormField(
max_digits=10, decimal_places=5,
initial=1,
label=_('Quantity'),
help_text=_('Stock quantity to assign'),
validators=[
MinValueValidator(0.001)
]
)
notes = forms.CharField(
required=False,
help_text=_('Notes')
)
class Meta:
model = StockItem
fields = [
'part',
'stock_item',
'quantity_to_install',
'notes',
]
def clean(self):
data = super().clean()
stock_item = data.get('stock_item', None)
quantity = data.get('quantity_to_install', None)
if stock_item and quantity and quantity > stock_item.quantity:
raise ValidationError({'quantity_to_install': _('Must not exceed available quantity')})
return data
class UninstallStockForm(forms.ModelForm): class UninstallStockForm(forms.ModelForm):
""" """
Form for uninstalling a stock item which is installed in another item. Form for uninstalling a stock item which is installed in another item.

View File

@ -0,0 +1,18 @@
# Generated by Django 3.0.7 on 2020-10-04 13:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stock', '0051_auto_20200928_0928'),
]
operations = [
migrations.AddField(
model_name='stockitem',
name='is_building',
field=models.BooleanField(default=False),
),
]

View File

@ -130,6 +130,7 @@ class StockItem(MPTTModel):
status: Status of this StockItem (ref: InvenTree.status_codes.StockStatus) status: Status of this StockItem (ref: InvenTree.status_codes.StockStatus)
notes: Extra notes field notes: Extra notes field
build: Link to a Build (if this stock item was created from a build) build: Link to a Build (if this stock item was created from a build)
is_building: Boolean field indicating if this stock item is currently being built
purchase_order: Link to a PurchaseOrder (if this stock item was created from a PurchaseOrder) purchase_order: Link to a PurchaseOrder (if this stock item was created from a PurchaseOrder)
infinite: If True this StockItem can never be exhausted infinite: If True this StockItem can never be exhausted
sales_order: Link to a SalesOrder object (if the StockItem has been assigned to a SalesOrder) sales_order: Link to a SalesOrder object (if the StockItem has been assigned to a SalesOrder)
@ -142,6 +143,7 @@ class StockItem(MPTTModel):
build_order=None, build_order=None,
belongs_to=None, belongs_to=None,
customer=None, customer=None,
is_building=False,
status__in=StockStatus.AVAILABLE_CODES status__in=StockStatus.AVAILABLE_CODES
) )
@ -273,11 +275,25 @@ class StockItem(MPTTModel):
# TODO - Find a test than can be perfomed... # TODO - Find a test than can be perfomed...
pass pass
# Ensure that the item cannot be assigned to itself
if self.belongs_to and self.belongs_to.pk == self.pk: if self.belongs_to and self.belongs_to.pk == self.pk:
raise ValidationError({ raise ValidationError({
'belongs_to': _('Item cannot belong to itself') 'belongs_to': _('Item cannot belong to itself')
}) })
# If the item is marked as "is_building", it must point to a build!
if self.is_building and not self.build:
raise ValidationError({
'build': _("Item must have a build reference if is_building=True")
})
# If the item points to a build, check that the Part references match
if self.build:
if not self.part == self.build.part:
raise ValidationError({
'build': _("Build reference does not point to the same part object")
})
def get_absolute_url(self): def get_absolute_url(self):
return reverse('stock-item-detail', kwargs={'pk': self.id}) return reverse('stock-item-detail', kwargs={'pk': self.id})
@ -389,6 +405,10 @@ class StockItem(MPTTModel):
related_name='build_outputs', related_name='build_outputs',
) )
is_building = models.BooleanField(
default=False,
)
purchase_order = models.ForeignKey( purchase_order = models.ForeignKey(
'order.PurchaseOrder', 'order.PurchaseOrder',
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@ -600,12 +620,13 @@ class StockItem(MPTTModel):
return self.installedItemCount() > 0 return self.installedItemCount() > 0
@transaction.atomic @transaction.atomic
def installIntoStockItem(self, otherItem, user, notes): def installStockItem(self, otherItem, quantity, user, notes):
""" """
Install this stock item into another stock item. Install another stock item into this stock item.
Args Args
otherItem: The stock item to install this item into otherItem: The stock item to install into this stock item
quantity: The quantity of stock to install
user: The user performing the operation user: The user performing the operation
notes: Any notes associated with the operation notes: Any notes associated with the operation
""" """
@ -614,18 +635,29 @@ class StockItem(MPTTModel):
if self.belongs_to is not None: if self.belongs_to is not None:
return False return False
# TODO - Are there any other checks that need to be performed at this stage? # If the quantity is less than the stock item, split the stock!
stock_item = otherItem.splitStock(quantity, None, user)
# Mark this stock item as belonging to the other one if stock_item is None:
self.belongs_to = otherItem stock_item = otherItem
self.save() # Assign the other stock item into this one
stock_item.belongs_to = self
stock_item.save()
# Add a transaction note! # Add a transaction note to the other item
self.addTransactionNote( stock_item.addTransactionNote(
_('Installed in stock item') + ' ' + str(otherItem.pk), _('Installed into stock item') + ' ' + str(self.pk),
user, user,
notes=notes notes=notes,
url=self.get_absolute_url()
)
# Add a transaction note to this item
self.addTransactionNote(
_('Installed stock item') + ' ' + str(stock_item.pk),
user, notes=notes,
url=stock_item.get_absolute_url()
) )
@transaction.atomic @transaction.atomic
@ -645,16 +677,31 @@ class StockItem(MPTTModel):
# TODO - Are there any other checks that need to be performed at this stage? # TODO - Are there any other checks that need to be performed at this stage?
# Add a transaction note to the parent item
self.belongs_to.addTransactionNote(
_("Uninstalled stock item") + ' ' + str(self.pk),
user,
notes=notes,
url=self.get_absolute_url(),
)
# Mark this stock item as *not* belonging to anyone
self.belongs_to = None self.belongs_to = None
self.location = location self.location = location
self.save() self.save()
if location:
url = location.get_absolute_url()
else:
url = ''
# Add a transaction note! # Add a transaction note!
self.addTransactionNote( self.addTransactionNote(
_('Uninstalled into location') + ' ' + str(location), _('Uninstalled into location') + ' ' + str(location),
user, user,
notes=notes notes=notes,
url=url
) )
@property @property
@ -688,6 +735,10 @@ class StockItem(MPTTModel):
if self.customer is not None: if self.customer is not None:
return False return False
# Not 'in stock' if it is building
if self.is_building:
return False
# Not 'in stock' if the status code makes it unavailable # Not 'in stock' if the status code makes it unavailable
if self.status in StockStatus.UNAVAILABLE_CODES: if self.status in StockStatus.UNAVAILABLE_CODES:
return False return False
@ -838,20 +889,20 @@ class StockItem(MPTTModel):
# Do not split a serialized part # Do not split a serialized part
if self.serialized: if self.serialized:
return return self
try: try:
quantity = Decimal(quantity) quantity = Decimal(quantity)
except (InvalidOperation, ValueError): except (InvalidOperation, ValueError):
return return self
# Doesn't make sense for a zero quantity # Doesn't make sense for a zero quantity
if quantity <= 0: if quantity <= 0:
return return self
# Also doesn't make sense to split the full amount # Also doesn't make sense to split the full amount
if quantity >= self.quantity: if quantity >= self.quantity:
return return self
# Create a new StockItem object, duplicating relevant fields # Create a new StockItem object, duplicating relevant fields
# Nullify the PK so a new record is created # Nullify the PK so a new record is created

View File

@ -0,0 +1,17 @@
{% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %}
<p>
{% trans "Install another StockItem into this item." %}
</p>
<p>
{% trans "Stock items can only be installed if they meet the following criteria" %}:
<ul>
<li>{% trans "The StockItem links to a Part which is in the BOM for this StockItem" %}</li>
<li>{% trans "The StockItem is currently in stock" %}</li>
</ul>
</p>
{% endblock %}

View File

@ -10,19 +10,7 @@
<h4>{% trans "Installed Stock Items" %}</h4> <h4>{% trans "Installed Stock Items" %}</h4>
<hr> <hr>
<div id='button-toolbar'> <table class='table table-striped table-condensed' id='installed-table'></table>
<div class='button-toolbar container-fluid' style='float: right;'>
<div class="btn-group">
<button id='stock-options' class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown">{% trans "Options" %}<span class="caret"></span></button>
<ul class="dropdown-menu">
<li><a href="#" id='multi-item-uninstall' title='{% trans "Uninstall selected stock items" %}'>{% trans "Uninstall" %}</a></li>
</ul>
</div>
</div>
</div>
<table class='table table-striped table-condensed' id='installed-table' data-toolbar='#button-toolbar'>
</table>
{% endblock %} {% endblock %}
@ -30,135 +18,14 @@
{{ block.super }} {{ block.super }}
$('#installed-table').inventreeTable({ loadInstalledInTable(
formatNoMatches: function() { $('#installed-table'),
return '{% trans "No stock items installed" %}'; {
}, stock_item: {{ item.pk }},
url: "{% url 'api-stock-list' %}", part: {{ item.part.pk }},
queryParams: { quantity: {{ item.quantity }},
installed_in: {{ item.id }}, }
part_detail: true, );
},
name: 'stock-item-installed',
url: "{% url 'api-stock-list' %}",
showColumns: true,
columns: [
{
checkbox: true,
title: '{% trans 'Select' %}',
searchable: false,
switchable: false,
},
{
field: 'pk',
title: 'ID',
visible: false,
switchable: false,
},
{
field: 'part_name',
title: '{% trans "Part" %}',
sortable: true,
formatter: function(value, row, index, field) {
var url = `/stock/item/${row.pk}/`;
var thumb = row.part_detail.thumbnail;
var name = row.part_detail.full_name;
html = imageHoverIcon(thumb) + renderLink(name, url);
return html;
}
},
{
field: 'IPN',
title: 'IPN',
sortable: true,
formatter: function(value, row, index, field) {
return row.part_detail.IPN;
},
},
{
field: 'part_description',
title: '{% trans "Description" %}',
sortable: true,
formatter: function(value, row, index, field) {
return row.part_detail.description;
}
},
{
field: 'quantity',
title: '{% trans "Stock" %}',
sortable: true,
formatter: function(value, row, index, field) {
var val = parseFloat(value);
// If there is a single unit with a serial number, use the serial number
if (row.serial && row.quantity == 1) {
val = '# ' + row.serial;
} else {
val = +val.toFixed(5);
}
var html = renderLink(val, `/stock/item/${row.pk}/`);
return html;
}
},
{
field: 'status',
title: '{% trans "Status" %}',
sortable: 'true',
formatter: function(value, row, index, field) {
return stockStatusDisplay(value);
},
},
{
field: 'batch',
title: '{% trans "Batch" %}',
sortable: true,
},
{
field: 'actions',
switchable: false,
title: '',
formatter: function(value, row) {
var pk = row.pk;
var html = `<div class='btn-group float-right' role='group'>`;
html += makeIconButton('fa-unlink', 'button-uninstall', pk, '{% trans "Uninstall item" %}');
html += `</div>`;
return html;
}
}
],
onLoadSuccess: function() {
var table = $('#installed-table');
// Find buttons and associate actions
table.find('.button-uninstall').click(function() {
var pk = $(this).attr('pk');
launchModalForm(
"{% url 'stock-item-uninstall' %}",
{
data: {
'items[]': [pk],
},
reload: true,
}
);
});
},
buttons: [
'#stock-options',
]
});
$('#multi-item-uninstall').click(function() { $('#multi-item-uninstall').click(function() {

View File

@ -1,6 +1,8 @@
{% extends "modal_form.html" %} {% extends "modal_form.html" %}
{% load i18n %}
{% block pre_form_content %} {% block pre_form_content %}
Create serialized items from this stock item.<br> {% trans "Create serialized items from this stock item." %}
Select quantity to serialize, and unique serial numbers. <br>
{% trans "Select quantity to serialize, and unique serial numbers." %}
{% endblock %} {% endblock %}

View File

@ -7,7 +7,9 @@ import datetime
from .models import StockLocation, StockItem, StockItemTracking from .models import StockLocation, StockItem, StockItemTracking
from .models import StockItemTestResult from .models import StockItemTestResult
from part.models import Part from part.models import Part
from build.models import Build
class StockTest(TestCase): class StockTest(TestCase):
@ -47,6 +49,35 @@ class StockTest(TestCase):
Part.objects.rebuild() Part.objects.rebuild()
StockItem.objects.rebuild() StockItem.objects.rebuild()
def test_is_building(self):
"""
Test that the is_building flag does not count towards stock.
"""
part = Part.objects.get(pk=1)
# Record the total stock count
n = part.total_stock
StockItem.objects.create(part=part, quantity=5)
# And there should be *no* items being build
self.assertEqual(part.quantity_being_built, 0)
build = Build.objects.create(part=part, title='A test build', quantity=1)
# Add some stock items which are "building"
for i in range(10):
StockItem.objects.create(
part=part, build=build,
quantity=10, is_building=True
)
# The "is_building" quantity should not be counted here
self.assertEqual(part.total_stock, n + 5)
self.assertEqual(part.quantity_being_built, 100)
def test_loc_count(self): def test_loc_count(self):
self.assertEqual(StockLocation.objects.count(), 7) self.assertEqual(StockLocation.objects.count(), 7)

View File

@ -25,6 +25,7 @@ stock_item_detail_urls = [
url(r'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'), url(r'^delete_test_data/', views.StockItemDeleteTestData.as_view(), name='stock-item-delete-test-data'),
url(r'^assign/', views.StockItemAssignToCustomer.as_view(), name='stock-item-assign'), url(r'^assign/', views.StockItemAssignToCustomer.as_view(), name='stock-item-assign'),
url(r'^return/', views.StockItemReturnToStock.as_view(), name='stock-item-return'), url(r'^return/', views.StockItemReturnToStock.as_view(), name='stock-item-return'),
url(r'^install/', views.StockItemInstall.as_view(), name='stock-item-install'),
url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'), url(r'^add_tracking/', views.StockItemTrackingCreate.as_view(), name='stock-tracking-create'),

View File

@ -683,6 +683,106 @@ class StockItemQRCode(QRCodeView):
return None return None
class StockItemInstall(AjaxUpdateView):
"""
View for manually installing stock items into
a particular stock item.
In contrast to the StockItemUninstall view,
only a single stock item can be installed at once.
The "part" to be installed must be provided in the GET query parameters.
"""
model = StockItem
form_class = StockForms.InstallStockForm
ajax_form_title = _('Install Stock Item')
ajax_template_name = "stock/item_install.html"
part = None
def get_stock_items(self):
"""
Return a list of stock items suitable for displaying to the user.
Requirements:
- Items must be in stock
Filters:
- Items can be filtered by Part reference
"""
items = StockItem.objects.filter(StockItem.IN_STOCK_FILTER)
# Filter by Part association
# Look at GET params
part_id = self.request.GET.get('part', None)
if part_id is None:
# Look at POST params
part_id = self.request.POST.get('part', None)
try:
self.part = Part.objects.get(pk=part_id)
items = items.filter(part=self.part)
except (ValueError, Part.DoesNotExist):
self.part = None
return items
def get_initial(self):
initials = super().get_initial()
items = self.get_stock_items()
# If there is a single stock item available, we can use it!
if items.count() == 1:
item = items.first()
initials['stock_item'] = item.pk
initials['quantity_to_install'] = item.quantity
if self.part:
initials['part'] = self.part
return initials
def get_form(self):
form = super().get_form()
form.fields['stock_item'].queryset = self.get_stock_items()
return form
def post(self, request, *args, **kwargs):
form = self.get_form()
valid = form.is_valid()
if valid:
# We assume by this point that we have a valid stock_item and quantity values
data = form.cleaned_data
other_stock_item = data['stock_item']
quantity = data['quantity_to_install']
notes = data['notes']
# Install the other stock item into this one
this_stock_item = self.get_object()
this_stock_item.installStockItem(other_stock_item, quantity, request.user, notes)
data = {
'form_valid': valid,
}
return self.renderJsonResponse(request, form, data=data)
class StockItemUninstall(AjaxView, FormMixin): class StockItemUninstall(AjaxView, FormMixin):
""" """
View for uninstalling one or more StockItems, View for uninstalling one or more StockItems,

View File

@ -39,6 +39,7 @@
<link rel="stylesheet" href="{% static 'css/select2.css' %}"> <link rel="stylesheet" href="{% static 'css/select2.css' %}">
<link rel="stylesheet" href="{% static 'css/select2-bootstrap.css' %}"> <link rel="stylesheet" href="{% static 'css/select2-bootstrap.css' %}">
<link rel="stylesheet" href="{% static 'css/bootstrap-toggle.css' %}"> <link rel="stylesheet" href="{% static 'css/bootstrap-toggle.css' %}">
<link rel="stylesheet" href="{% static 'css/bootstrap-table-filter-control.css' %}">
<link rel="stylesheet" href="{% static 'css/inventree.css' %}"> <link rel="stylesheet" href="{% static 'css/inventree.css' %}">
<link rel="stylesheet" href="{% get_color_theme_css user.get_username %}"> <link rel="stylesheet" href="{% get_color_theme_css user.get_username %}">
@ -99,6 +100,8 @@ InvenTree
<script type='text/javascript' src="{% static 'script/bootstrap/bootstrap-table-en-US.min.js' %}"></script> <script type='text/javascript' src="{% static 'script/bootstrap/bootstrap-table-en-US.min.js' %}"></script>
<script type='text/javascript' src="{% static 'script/bootstrap/bootstrap-table-group-by.js' %}"></script> <script type='text/javascript' src="{% static 'script/bootstrap/bootstrap-table-group-by.js' %}"></script>
<script type='text/javascript' src="{% static 'script/bootstrap/bootstrap-toggle.js' %}"></script> <script type='text/javascript' src="{% static 'script/bootstrap/bootstrap-toggle.js' %}"></script>
<script type='text/javascript' src="{% static 'script/bootstrap/bootstrap-table-filter-control.js' %}"></script>
<!-- <script type='text/javascript' src="{% static 'script/bootstrap/filter-control-utils.js' %}"></script> -->
<script type="text/javascript" src="{% static 'script/select2/select2.js' %}"></script> <script type="text/javascript" src="{% static 'script/select2/select2.js' %}"></script>
<script type='text/javascript' src="{% static 'script/moment.js' %}"></script> <script type='text/javascript' src="{% static 'script/moment.js' %}"></script>

View File

@ -169,6 +169,10 @@ function loadBomTable(table, options) {
// Let's make it a bit more pretty // Let's make it a bit more pretty
text = parseFloat(text); text = parseFloat(text);
if (row.optional) {
text += " ({% trans "Optional" %})";
}
if (row.overage) { if (row.overage) {
text += "<small> (+" + row.overage + ") </small>"; text += "<small> (+" + row.overage + ") </small>";
} }

View File

@ -163,6 +163,72 @@ function loadSimplePartTable(table, url, options={}) {
} }
function loadParametricPartTable(table, options={}) {
/* Load parametric table for part parameters
*
* Args:
* - table: HTML reference to the table
* - table_headers: Unique parameters found in category
* - table_data: Parameters data
*/
var table_headers = options.headers
var table_data = options.data
var columns = [];
for (header of table_headers) {
if (header === 'part') {
columns.push({
field: header,
title: '{% trans 'Part' %}',
sortable: true,
sortName: 'name',
formatter: function(value, row, index, field) {
var name = '';
if (row.IPN) {
name += row.IPN + ' | ' + row.name;
} else {
name += row.name;
}
return renderLink(name, '/part/' + row.pk + '/');
}
});
} else if (header === 'description') {
columns.push({
field: header,
title: '{% trans 'Description' %}',
sortable: true,
});
} else {
columns.push({
field: header,
title: header,
sortable: true,
filterControl: 'input',
/* TODO: Search icons are not displayed */
/*clear: 'fa-times icon-red',*/
});
}
}
$(table).inventreeTable({
sortName: 'part',
queryParams: table_headers,
groupBy: false,
name: options.name || 'parametric',
formatNoMatches: function() { return "{% trans "No parts found" %}"; },
columns: columns,
showColumns: true,
data: table_data,
filterControl: true,
});
}
function loadPartTable(table, url, options={}) { function loadPartTable(table, url, options={}) {
/* Load part listing data into specified table. /* Load part listing data into specified table.
* *

View File

@ -470,10 +470,16 @@ function loadStockTable(table, options) {
if (row.customer) { if (row.customer) {
html += `<span class='fas fa-user-tie label-right' title='{% trans "Stock item has been assigned to customer" %}'></span>`; html += `<span class='fas fa-user-tie label-right' title='{% trans "Stock item has been assigned to customer" %}'></span>`;
} else if (row.build_order) { } else {
html += `<span class='fas fa-tools label-right' title='{% trans "Stock item was assigned to a build order" %}'></span>`; if (row.build_order) {
} else if (row.sales_order) { html += `<span class='fas fa-tools label-right' title='{% trans "Stock item was assigned to a build order" %}'></span>`;
html += `<span class='fas fa-dollar-sign label-right' title='{% trans "Stock item was assigned to a sales order" %}'></span>`; } else if (row.sales_order) {
html += `<span class='fas fa-dollar-sign label-right' title='{% trans "Stock item was assigned to a sales order" %}'></span>`;
}
}
if (row.belongs_to) {
html += `<span class='fas fa-box label-right' title='{% trans "Stock item has been installed in another item" %}'></span>`;
} }
// Special stock status codes // Special stock status codes
@ -520,6 +526,9 @@ function loadStockTable(table, options) {
} else if (row.customer) { } else if (row.customer) {
var text = "{% trans "Shipped to customer" %}"; var text = "{% trans "Shipped to customer" %}";
return renderLink(text, `/company/${row.customer}/assigned-stock/`); return renderLink(text, `/company/${row.customer}/assigned-stock/`);
} else if (row.sales_order) {
var text = `{% trans "Assigned to sales order" %}`;
return renderLink(text, `/order/sales-order/${row.sales_order}/`);
} }
else if (value) { else if (value) {
return renderLink(value, `/stock/location/${row.location}/`); return renderLink(value, `/stock/location/${row.location}/`);
@ -799,3 +808,300 @@ function createNewStockItem(options) {
launchModalForm("{% url 'stock-item-create' %}", options); launchModalForm("{% url 'stock-item-create' %}", options);
} }
function loadInstalledInTable(table, options) {
/*
* Display a table showing the stock items which are installed in this stock item.
* This is a multi-level tree table, where the "top level" items are Part objects,
* and the children of each top-level item are the associated installed stock items.
*
* The process for retrieving data and displaying the table is as follows:
*
* A) Get BOM data for the stock item
* - It is assumed that the stock item will be for an assembly
* (otherwise why are we installing stuff anyway?)
* - Request BOM items for stock_item.part (and only for trackable sub items)
*
* B) Add parts to table
* - Create rows for each trackable sub-part in the table
*
* C) Gather installed stock item data
* - Get the list of installed stock items via the API
* - If the Part reference is already in the table, add the sub-item as a child
* - If this is a stock item for a *new* part, request that part from the API,
* and add that part as a new row, then add the stock item as a child of that part
*
* D) Enjoy!
*
*
* And the options object contains the following things:
*
* - stock_item: The PK of the master stock_item object
* - part: The PK of the Part reference of the stock_item object
* - quantity: The quantity of the stock item
*/
function updateCallbacks() {
// Setup callback functions when buttons are pressed
table.find('.button-install').click(function() {
var pk = $(this).attr('pk');
launchModalForm(
`/stock/item/${options.stock_item}/install/`,
{
data: {
part: pk,
},
success: function() {
// Refresh entire table!
table.bootstrapTable('refresh');
}
}
);
});
}
table.inventreeTable(
{
url: "{% url 'api-bom-list' %}",
queryParams: {
part: options.part,
trackable: true,
sub_part_detail: true,
},
showColumns: false,
name: 'installed-in',
detailView: true,
detailViewByClick: true,
detailFilter: function(index, row) {
return row.installed_count && row.installed_count > 0;
},
detailFormatter: function(index, row, element) {
var subTableId = `installed-table-${row.sub_part}`;
var html = `<div class='sub-table'><table class='table table-condensed table-striped' id='${subTableId}'></table></div>`;
element.html(html);
var subTable = $(`#${subTableId}`);
// Display a "sub table" showing all the linked stock items
subTable.bootstrapTable({
data: row.installed_items,
showHeader: true,
columns: [
{
field: 'item',
title: '{% trans "Stock Item" %}',
formatter: function(value, subrow, index, field) {
var pk = subrow.pk;
var html = '';
if (subrow.serial && subrow.quantity == 1) {
html += `{% trans "Serial" %}: ${subrow.serial}`;
} else {
html += `{% trans "Quantity" %}: ${subrow.quantity}`;
}
return renderLink(html, `/stock/item/${subrow.pk}/`);
},
},
{
field: 'status',
title: '{% trans "Status" %}',
formatter: function(value, subrow, index, field) {
return stockStatusDisplay(value);
}
},
{
field: 'batch',
title: '{% trans "Batch" %}',
},
{
field: 'actions',
title: '',
formatter: function(value, subrow, index) {
var pk = subrow.pk;
var html = '';
// Add some buttons yo!
html += `<div class='btn-group float-right' role='group'>`;
html += makeIconButton('fa-unlink', 'button-uninstall', pk, "{% trans "Uninstall stock item" %}");
html += `</div>`;
return html;
}
}
],
onPostBody: function() {
// Setup button callbacks
subTable.find('.button-uninstall').click(function() {
var pk = $(this).attr('pk');
launchModalForm(
"{% url 'stock-item-uninstall' %}",
{
data: {
'items[]': [pk],
},
success: function() {
// Refresh entire table!
table.bootstrapTable('refresh');
}
}
);
});
}
});
},
columns: [
{
checkbox: true,
title: '{% trans 'Select' %}',
searchable: false,
switchable: false,
},
{
field: 'pk',
title: 'ID',
visible: false,
switchable: false,
},
{
field: 'part',
title: '{% trans "Part" %}',
sortable: true,
formatter: function(value, row, index, field) {
var url = `/part/${row.sub_part}/`;
var thumb = row.sub_part_detail.thumbnail;
var name = row.sub_part_detail.full_name;
html = imageHoverIcon(thumb) + renderLink(name, url);
if (row.not_in_bom) {
html = `<i>${html}</i>`
}
return html;
}
},
{
field: 'installed',
title: '{% trans "Installed" %}',
sortable: false,
formatter: function(value, row, index, field) {
// Construct a progress showing how many items have been installed
var installed = row.installed_count || 0;
var required = row.quantity || 0;
required *= options.quantity;
var progress = makeProgressBar(installed, required, {
id: row.sub_part.pk,
});
return progress;
}
},
{
field: 'actions',
switchable: false,
formatter: function(value, row) {
var pk = row.sub_part;
var html = `<div class='btn-group float-right' role='group'>`;
html += makeIconButton('fa-link', 'button-install', pk, '{% trans "Install item" %}');
html += `</div>`;
return html;
}
}
],
onLoadSuccess: function() {
// Grab a list of parts which are actually installed in this stock item
inventreeGet(
"{% url 'api-stock-list' %}",
{
installed_in: options.stock_item,
part_detail: true,
},
{
success: function(stock_items) {
var table_data = table.bootstrapTable('getData');
stock_items.forEach(function(item) {
var match = false;
for (var idx = 0; idx < table_data.length; idx++) {
var row = table_data[idx];
// Check each row in the table to see if this stock item matches
table_data.forEach(function(row) {
// Match on "sub_part"
if (row.sub_part == item.part) {
// First time?
if (row.installed_count == null) {
row.installed_count = 0;
row.installed_items = [];
}
row.installed_count += item.quantity;
row.installed_items.push(item);
// Push the row back into the table
table.bootstrapTable('updateRow', idx, row, true);
match = true;
}
});
if (match) {
break;
}
}
if (!match) {
// The stock item did *not* match any items in the BOM!
// Add a new row to the table...
// Contruct a new "row" to add to the table
var new_row = {
sub_part: item.part,
sub_part_detail: item.part_detail,
not_in_bom: true,
installed_count: item.quantity,
installed_items: [item],
};
table.bootstrapTable('append', [new_row]);
}
});
// Update button callback links
updateCallbacks();
}
}
);
updateCallbacks();
},
}
);
}

View File

@ -15,9 +15,16 @@
</div> </div>
<div class="navbar-collapse collapse"> <div class="navbar-collapse collapse">
<ul class="nav navbar-nav"> <ul class="nav navbar-nav">
{% if perms.part.view_part or perms.part.view_partcategory %}
<li><a href="{% url 'part-index' %}"><span class='fas fa-shapes icon-header'></span>{% trans "Parts" %}</a></li> <li><a href="{% url 'part-index' %}"><span class='fas fa-shapes icon-header'></span>{% trans "Parts" %}</a></li>
{% endif %}
{% if perms.stock.view_stockitem or perms.part.view_stocklocation %}
<li><a href="{% url 'stock-index' %}"><span class='fas fa-boxes icon-header'></span>{% trans "Stock" %}</a></li> <li><a href="{% url 'stock-index' %}"><span class='fas fa-boxes icon-header'></span>{% trans "Stock" %}</a></li>
{% endif %}
{% if perms.build.view_build %}
<li><a href="{% url 'build-index' %}"><span class='fas fa-tools icon-header'></span>{% trans "Build" %}</a></li> <li><a href="{% url 'build-index' %}"><span class='fas fa-tools icon-header'></span>{% trans "Build" %}</a></li>
{% endif %}
{% if perms.order.view_purchaseorder %}
<li class='nav navbar-nav'> <li class='nav navbar-nav'>
<a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='fas fa-shopping-cart icon-header'></span>{% trans "Buy" %}</a> <a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='fas fa-shopping-cart icon-header'></span>{% trans "Buy" %}</a>
<ul class='dropdown-menu'> <ul class='dropdown-menu'>
@ -26,6 +33,8 @@
<li><a href="{% url 'po-index' %}"><span class='fas fa-list icon-header'></span>{% trans "Purchase Orders" %}</a></li> <li><a href="{% url 'po-index' %}"><span class='fas fa-list icon-header'></span>{% trans "Purchase Orders" %}</a></li>
</ul> </ul>
</li> </li>
{% endif %}
{% if perms.order.view_salesorder %}
<li class='nav navbar-nav'> <li class='nav navbar-nav'>
<a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='fas fa-truck icon-header'></span>{% trans "Sell" %}</a> <a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='fas fa-truck icon-header'></span>{% trans "Sell" %}</a>
<ul class='dropdown-menu'> <ul class='dropdown-menu'>
@ -33,6 +42,7 @@
<li><a href="{% url 'so-index' %}"><span class='fas fa-list icon-header'></span>{% trans "Sales Orders" %}</a></li> <li><a href="{% url 'so-index' %}"><span class='fas fa-list icon-header'></span>{% trans "Sales Orders" %}</a></li>
</ul> </ul>
</li> </li>
{% endif %}
</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

@ -1,3 +1,135 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# from __future__ import unicode_literals from __future__ import unicode_literals
# from django.contrib import admin
from django.utils.translation import ugettext_lazy as _
from django.contrib import admin
from django import forms
from django.contrib.auth import get_user_model
from django.contrib.admin.widgets import FilteredSelectMultiple
from django.contrib.auth.models import Group
from django.contrib.auth.admin import UserAdmin
from users.models import RuleSet
User = get_user_model()
class RuleSetInline(admin.TabularInline):
"""
Class for displaying inline RuleSet data in the Group admin page.
"""
model = RuleSet
can_delete = False
verbose_name = 'Ruleset'
verbose_plural_name = 'Rulesets'
fields = ['name'] + [option for option in RuleSet.RULE_OPTIONS]
readonly_fields = ['name']
max_num = len(RuleSet.RULESET_CHOICES)
min_num = 1
extra = 0
class InvenTreeGroupAdminForm(forms.ModelForm):
"""
Custom admin form for the Group model.
Adds the ability for editing user membership directly in the group admin page.
"""
class Meta:
model = Group
exclude = []
fields = [
'name',
'users',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.pk:
# Populate the users field with the current Group users.
self.fields['users'].initial = self.instance.user_set.all()
# Add the users field.
users = forms.ModelMultipleChoiceField(
queryset=User.objects.all(),
required=False,
widget=FilteredSelectMultiple('users', False),
label=_('Users'),
help_text=_('Select which users are assigned to this group')
)
def save_m2m(self):
# Add the users to the Group.
self.instance.user_set.set(self.cleaned_data['users'])
def save(self, *args, **kwargs):
# Default save
instance = super().save()
# Save many-to-many data
self.save_m2m()
return instance
class RoleGroupAdmin(admin.ModelAdmin):
"""
Custom admin interface for the Group model
"""
form = InvenTreeGroupAdminForm
inlines = [
RuleSetInline,
]
def get_formsets_with_inlines(self, request, obj=None):
for inline in self.get_inline_instances(request, obj):
# Hide RuleSetInline in the 'Add role' view
if not isinstance(inline, RuleSetInline) or obj is not None:
yield inline.get_formset(request, obj), inline
filter_horizontal = ['permissions']
# Save inlines before model
# https://stackoverflow.com/a/14860703/12794913
def save_model(self, request, obj, form, change):
if obj is not None:
# Save model immediately only if in 'Add role' view
super().save_model(request, obj, form, change)
else:
pass # don't actually save the parent instance
def save_formset(self, request, form, formset, change):
formset.save() # this will save the children
form.instance.save() # form.instance is the parent
class InvenTreeUserAdmin(UserAdmin):
"""
Custom admin page for the User model.
Hides the "permissions" view as this is now handled
entirely by groups and RuleSets.
(And it's confusing!)
"""
fieldsets = (
(None, {'fields': ('username', 'password')}),
(_('Personal info'), {'fields': ('first_name', 'last_name', 'email')}),
(_('Permissions'), {
'fields': ('is_active', 'is_staff', 'is_superuser', 'groups'),
}),
(_('Important dates'), {'fields': ('last_login', 'date_joined')}),
)
admin.site.unregister(Group)
admin.site.register(Group, RoleGroupAdmin)
admin.site.unregister(User)
admin.site.register(User, InvenTreeUserAdmin)

View File

@ -1,8 +1,33 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from django.db.utils import OperationalError, ProgrammingError
from django.apps import AppConfig from django.apps import AppConfig
class UsersConfig(AppConfig): class UsersConfig(AppConfig):
name = 'users' name = 'users'
def ready(self):
try:
self.assign_permissions()
except (OperationalError, ProgrammingError):
pass
def assign_permissions(self):
from django.contrib.auth.models import Group
from users.models import RuleSet, update_group_roles
# First, delete any rule_set objects which have become outdated!
for rule in RuleSet.objects.all():
if rule.name not in RuleSet.RULESET_NAMES:
print("need to delete:", rule.name)
rule.delete()
# Update group permission assignments for all groups
for group in Group.objects.all():
update_group_roles(group)

View File

@ -0,0 +1,31 @@
# Generated by Django 3.0.7 on 2020-10-03 13:44
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0011_update_proxy_permissions'),
]
operations = [
migrations.CreateModel(
name='RuleSet',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(choices=[('general', 'General'), ('admin', 'Admin'), ('part', 'Parts'), ('stock', 'Stock'), ('build', 'Build Orders'), ('supplier', 'Suppliers'), ('purchase_order', 'Purchase Orders'), ('customer', 'Customers'), ('sales_order', 'Sales Orders')], help_text='Permission set', max_length=50)),
('can_view', models.BooleanField(default=True, help_text='Permission to view items', verbose_name='View')),
('can_add', models.BooleanField(default=False, help_text='Permission to add items', verbose_name='Create')),
('can_change', models.BooleanField(default=False, help_text='Permissions to edit items', verbose_name='Update')),
('can_delete', models.BooleanField(default=False, help_text='Permission to delete items', verbose_name='Delete')),
('group', models.ForeignKey(help_text='Group', on_delete=django.db.models.deletion.CASCADE, related_name='rule_sets', to='auth.Group')),
],
options={
'unique_together': {('name', 'group')},
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.0.7 on 2020-10-04 01:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='ruleset',
name='name',
field=models.CharField(choices=[('admin', 'Admin'), ('part', 'Parts'), ('stock', 'Stock'), ('build', 'Build Orders'), ('purchase_order', 'Purchase Orders'), ('sales_order', 'Sales Orders')], help_text='Permission set', max_length=50),
),
]

View File

View File

@ -1 +1,317 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.dispatch import receiver
from django.db.models.signals import post_save
class RuleSet(models.Model):
"""
A RuleSet is somewhat like a superset of the django permission class,
in that in encapsulates a bunch of permissions.
There are *many* apps models used within InvenTree,
so it makes sense to group them into "roles".
These roles translate (roughly) to the menu options available.
Each role controls permissions for a number of database tables,
which are then handled using the normal django permissions approach.
"""
RULESET_CHOICES = [
('admin', _('Admin')),
('part', _('Parts')),
('stock', _('Stock')),
('build', _('Build Orders')),
('purchase_order', _('Purchase Orders')),
('sales_order', _('Sales Orders')),
]
RULESET_NAMES = [
choice[0] for choice in RULESET_CHOICES
]
RULESET_MODELS = {
'admin': [
'auth_group',
'auth_user',
'auth_permission',
'authtoken_token',
'users_ruleset',
],
'part': [
'part_part',
'part_bomitem',
'part_partcategory',
'part_partattachment',
'part_partsellpricebreak',
'part_parttesttemplate',
'part_partparametertemplate',
'part_partparameter',
],
'stock': [
'stock_stockitem',
'stock_stocklocation',
'stock_stockitemattachment',
'stock_stockitemtracking',
'stock_stockitemtestresult',
],
'build': [
'part_part',
'part_partcategory',
'part_bomitem',
'build_build',
'build_builditem',
'stock_stockitem',
'stock_stocklocation',
],
'purchase_order': [
'company_company',
'company_supplierpart',
'company_supplierpricebreak',
'order_purchaseorder',
'order_purchaseorderattachment',
'order_purchaseorderlineitem',
],
'sales_order': [
'company_company',
'order_salesorder',
'order_salesorderattachment',
'order_salesorderlineitem',
'order_salesorderallocation',
]
}
# Database models we ignore permission sets for
RULESET_IGNORE = [
# Core django models (not user configurable)
'admin_logentry',
'contenttypes_contenttype',
'sessions_session',
# Models which currently do not require permissions
'common_colortheme',
'common_currency',
'common_inventreesetting',
'company_contact',
'label_stockitemlabel',
'report_reportasset',
'report_testreport',
'part_partstar',
]
RULE_OPTIONS = [
'can_view',
'can_add',
'can_change',
'can_delete',
]
class Meta:
unique_together = (
('name', 'group'),
)
name = models.CharField(
max_length=50,
choices=RULESET_CHOICES,
blank=False,
help_text=_('Permission set')
)
group = models.ForeignKey(
Group,
related_name='rule_sets',
blank=False, null=False,
on_delete=models.CASCADE,
help_text=_('Group'),
)
can_view = models.BooleanField(verbose_name=_('View'), default=True, help_text=_('Permission to view items'))
can_add = models.BooleanField(verbose_name=_('Create'), default=False, help_text=_('Permission to add items'))
can_change = models.BooleanField(verbose_name=_('Update'), default=False, help_text=_('Permissions to edit items'))
can_delete = models.BooleanField(verbose_name=_('Delete'), default=False, help_text=_('Permission to delete items'))
@staticmethod
def get_model_permission_string(model, permission):
"""
Construct the correctly formatted permission string,
given the app_model name, and the permission type.
"""
app, model = model.split('_')
return "{app}.{perm}_{model}".format(
app=app,
perm=permission,
model=model
)
def __str__(self):
return self.name
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
def get_models(self):
"""
Return the database tables / models that this ruleset covers.
"""
return self.RULESET_MODELS.get(self.name, [])
def update_group_roles(group, debug=False):
"""
Iterates through all of the RuleSets associated with the group,
and ensures that the correct permissions are either applied or removed from the group.
This function is called under the following conditions:
a) Whenever the InvenTree database is launched
b) Whenver the group object is updated
The RuleSet model has complete control over the permissions applied to any group.
"""
# List of permissions already associated with this group
group_permissions = set()
# Iterate through each permission already assigned to this group,
# and create a simplified permission key string
for p in group.permissions.all():
(permission, app, model) = p.natural_key()
permission_string = '{app}.{perm}'.format(
app=app,
perm=permission
)
group_permissions.add(permission_string)
# List of permissions which must be added to the group
permissions_to_add = set()
# List of permissions which must be removed from the group
permissions_to_delete = set()
def add_model(name, action, allowed):
"""
Add a new model to the pile:
args:
name - The name of the model e.g. part_part
action - The permission action e.g. view
allowed - Whether or not the action is allowed
"""
if action not in ['view', 'add', 'change', 'delete']:
raise ValueError("Action {a} is invalid".format(a=action))
permission_string = RuleSet.get_model_permission_string(model, action)
if allowed:
# An 'allowed' action is always preferenced over a 'forbidden' action
if permission_string in permissions_to_delete:
permissions_to_delete.remove(permission_string)
if permission_string not in group_permissions:
permissions_to_add.add(permission_string)
else:
# A forbidden action will be ignored if we have already allowed it
if permission_string not in permissions_to_add:
if permission_string in group_permissions:
permissions_to_delete.add(permission_string)
# Get all the rulesets associated with this group
for r in RuleSet.RULESET_CHOICES:
rulename = r[0]
try:
ruleset = RuleSet.objects.get(group=group, name=rulename)
except RuleSet.DoesNotExist:
# Create the ruleset with default values (if it does not exist)
ruleset = RuleSet.objects.create(group=group, name=rulename)
# Which database tables does this RuleSet touch?
models = ruleset.get_models()
for model in models:
# Keep track of the available permissions for each model
add_model(model, 'view', ruleset.can_view)
add_model(model, 'add', ruleset.can_add)
add_model(model, 'change', ruleset.can_change)
add_model(model, 'delete', ruleset.can_delete)
def get_permission_object(permission_string):
"""
Find the permission object in the database,
from the simplified permission string
Args:
permission_string - a simplified permission_string e.g. 'part.view_partcategory'
Returns the permission object in the database associated with the permission string
"""
(app, perm) = permission_string.split('.')
(permission_name, model) = perm.split('_')
try:
content_type = ContentType.objects.get(app_label=app, model=model)
permission = Permission.objects.get(content_type=content_type, codename=perm)
except ContentType.DoesNotExist:
print(f"Error: Could not find permission matching '{permission_string}'")
permission = None
return permission
# Add any required permissions to the group
for perm in permissions_to_add:
permission = get_permission_object(perm)
group.permissions.add(permission)
if debug:
print(f"Adding permission {perm} to group {group.name}")
# Remove any extra permissions from the group
for perm in permissions_to_delete:
permission = get_permission_object(perm)
group.permissions.remove(permission)
if debug:
print(f"Removing permission {perm} from group {group.name}")
@receiver(post_save, sender=Group)
def create_missing_rule_sets(sender, instance, **kwargs):
"""
Called *after* a Group object is saved.
As the linked RuleSet instances are saved *before* the Group,
then we can now use these RuleSet values to update the
group permissions.
"""
update_group_roles(instance)

View File

@ -1,4 +1,157 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# from __future__ import unicode_literals from __future__ import unicode_literals
# from django.test import TestCase from django.test import TestCase
from django.apps import apps
from django.contrib.auth.models import Group
from users.models import RuleSet
class RuleSetModelTest(TestCase):
"""
Some simplistic tests to ensure the RuleSet model is setup correctly.
"""
def test_ruleset_models(self):
keys = RuleSet.RULESET_MODELS.keys()
# Check if there are any rulesets which do not have models defined
missing = [name for name in RuleSet.RULESET_NAMES if name not in keys]
if len(missing) > 0:
print("The following rulesets do not have models assigned:")
for m in missing:
print("-", m)
# Check if models have been defined for a ruleset which is incorrect
extra = [name for name in keys if name not in RuleSet.RULESET_NAMES]
if len(extra) > 0:
print("The following rulesets have been improperly added to RULESET_MODELS:")
for e in extra:
print("-", e)
# Check that each ruleset has models assigned
empty = [key for key in keys if len(RuleSet.RULESET_MODELS[key]) == 0]
if len(empty) > 0:
print("The following rulesets have empty entries in RULESET_MODELS:")
for e in empty:
print("-", e)
self.assertEqual(len(missing), 0)
self.assertEqual(len(extra), 0)
self.assertEqual(len(empty), 0)
def test_model_names(self):
"""
Test that each model defined in the rulesets is valid,
based on the database schema!
"""
available_models = apps.get_models()
available_tables = set()
# Extract each available database model and construct a formatted string
for model in available_models:
label = model.objects.model._meta.label
label = label.replace('.', '_').lower()
available_tables.add(label)
assigned_models = set()
# Now check that each defined model is a valid table name
for key in RuleSet.RULESET_MODELS.keys():
models = RuleSet.RULESET_MODELS[key]
for m in models:
assigned_models.add(m)
missing_models = set()
for model in available_tables:
if model not in assigned_models and model not in RuleSet.RULESET_IGNORE:
missing_models.add(model)
if len(missing_models) > 0:
print("The following database models are not covered by the defined RuleSet permissions:")
for m in missing_models:
print("-", m)
extra_models = set()
defined_models = set()
for model in assigned_models:
defined_models.add(model)
for model in RuleSet.RULESET_IGNORE:
defined_models.add(model)
for model in defined_models:
if model not in available_tables:
extra_models.add(model)
if len(extra_models) > 0:
print("The following RuleSet permissions do not match a database model:")
for m in extra_models:
print("-", m)
self.assertEqual(len(missing_models), 0)
self.assertEqual(len(extra_models), 0)
def test_permission_assign(self):
"""
Test that the permission assigning works!
"""
# Create a new group
group = Group.objects.create(name="Test group")
rulesets = group.rule_sets.all()
# Rulesets should have been created automatically for this group
self.assertEqual(rulesets.count(), len(RuleSet.RULESET_CHOICES))
# Check that all permissions have been assigned permissions?
permission_set = set()
for models in RuleSet.RULESET_MODELS.values():
for model in models:
permission_set.add(model)
# Every ruleset by default sets one permission, the "view" permission set
self.assertEqual(group.permissions.count(), len(permission_set))
# Add some more rules
for rule in rulesets:
rule.can_add = True
rule.can_change = True
rule.save()
group.save()
# There should now be three permissions for each rule set
self.assertEqual(group.permissions.count(), 3 * len(permission_set))
# Now remove *all* permissions
for rule in rulesets:
rule.can_view = False
rule.can_add = False
rule.can_change = False
rule.can_delete = False
rule.save()
group.save()
# There should now not be any permissions assigned to this group
self.assertEqual(group.permissions.count(), 0)

View File

@ -25,5 +25,6 @@ django-stdimage==5.1.1 # Advanced ImageField management
django-tex==1.1.7 # LaTeX PDF export django-tex==1.1.7 # LaTeX PDF export
django-weasyprint==1.0.1 # HTML PDF export django-weasyprint==1.0.1 # HTML PDF export
django-debug-toolbar==2.2 # Debug / profiling toolbar django-debug-toolbar==2.2 # Debug / profiling toolbar
django-admin-shell==0.1.2 # Python shell for the admin interface
inventree # Install the latest version of the InvenTree API python library inventree # Install the latest version of the InvenTree API python library

View File

@ -22,7 +22,8 @@ def apps():
'part', 'part',
'report', 'report',
'stock', 'stock',
'InvenTree' 'InvenTree',
'users',
] ]
def localDir(): def localDir():