mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'upstream/master'
This commit is contained in:
commit
8f6c26c7d3
@ -138,6 +138,7 @@ INSTALLED_APPS = [
|
||||
'part.apps.PartConfig',
|
||||
'report.apps.ReportConfig',
|
||||
'stock.apps.StockConfig',
|
||||
'users.apps.UsersConfig',
|
||||
|
||||
# Third part add-ons
|
||||
'django_filters', # Extended filter functionality
|
||||
@ -153,6 +154,7 @@ INSTALLED_APPS = [
|
||||
'markdownx', # Markdown editing
|
||||
'markdownify', # Markdown template rendering
|
||||
'django_tex', # LaTeX output
|
||||
'django_admin_shell', # Python shell for the admin interface
|
||||
]
|
||||
|
||||
LOGGING = {
|
||||
|
13
InvenTree/InvenTree/static/css/bootstrap-table-filter-control.css
vendored
Normal file
13
InvenTree/InvenTree/static/css/bootstrap-table-filter-control.css
vendored
Normal 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;
|
||||
}
|
3021
InvenTree/InvenTree/static/script/bootstrap/bootstrap-table-filter-control.js
vendored
Normal file
3021
InvenTree/InvenTree/static/script/bootstrap/bootstrap-table-filter-control.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
2361
InvenTree/InvenTree/static/script/bootstrap/filter-control-utils.js
Normal file
2361
InvenTree/InvenTree/static/script/bootstrap/filter-control-utils.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -105,9 +105,14 @@ function makeProgressBar(value, maximum, opts) {
|
||||
var options = opts || {};
|
||||
|
||||
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) {
|
||||
percent = 100;
|
||||
@ -115,18 +120,28 @@ function makeProgressBar(value, maximum, opts) {
|
||||
|
||||
var extraclass = '';
|
||||
|
||||
if (value > maximum) {
|
||||
if (maximum) {
|
||||
// TODO - Special color?
|
||||
}
|
||||
else if (value > maximum) {
|
||||
extraclass='progress-bar-over';
|
||||
} else if (value < maximum) {
|
||||
extraclass = 'progress-bar-under';
|
||||
}
|
||||
|
||||
var text = value;
|
||||
|
||||
if (maximum) {
|
||||
text += ' / ';
|
||||
text += maximum;
|
||||
}
|
||||
|
||||
var id = options.id || 'progress-bar';
|
||||
|
||||
return `
|
||||
<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-value'>${value} / ${maximum}</div>
|
||||
<div class='progress-value'>${text}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
@ -109,10 +109,20 @@ $.fn.inventreeTable = function(options) {
|
||||
options.pagination = true;
|
||||
options.pageSize = inventreeLoad(varName, 25);
|
||||
options.pageList = [25, 50, 100, 250, 'all'];
|
||||
|
||||
options.rememberOrder = true;
|
||||
options.sortable = true;
|
||||
options.search = true;
|
||||
options.showColumns = true;
|
||||
|
||||
if (options.sortable == null) {
|
||||
options.sortable = true;
|
||||
}
|
||||
|
||||
if (options.search == null) {
|
||||
options.search = true;
|
||||
}
|
||||
|
||||
if (options.showColumns == null) {
|
||||
options.showColumns = true;
|
||||
}
|
||||
|
||||
// Callback to save pagination data
|
||||
options.onPageChange = function(number, size) {
|
||||
|
@ -117,6 +117,7 @@ urlpatterns = [
|
||||
url(r'^edit-user/', EditUserView.as_view(), name='edit-user'),
|
||||
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'^qr_code/', include(qr_code_urls, namespace='qr_code')),
|
||||
|
@ -38,4 +38,5 @@ class CompanyConfig(AppConfig):
|
||||
company.image = None
|
||||
company.save()
|
||||
except (OperationalError, ProgrammingError):
|
||||
print("Could not generate Company thumbnails")
|
||||
# Getting here probably meant the database was in test mode
|
||||
pass
|
||||
|
@ -24,7 +24,6 @@ def reverse_association(apps, schema_editor):
|
||||
# Exit if there are no SupplierPart objects
|
||||
# This crucial otherwise the unit test suite fails!
|
||||
if SupplierPart.objects.count() == 0:
|
||||
print("No SupplierPart objects - skipping")
|
||||
return
|
||||
|
||||
print("Reversing migration for manufacturer association")
|
||||
@ -105,7 +104,6 @@ def associate_manufacturers(apps, schema_editor):
|
||||
# Exit if there are no SupplierPart objects
|
||||
# This crucial otherwise the unit test suite fails!
|
||||
if SupplierPart.objects.count() == 0:
|
||||
print("No SupplierPart objects - skipping")
|
||||
return
|
||||
|
||||
# Link a 'manufacturer_name' to a 'Company'
|
||||
|
Binary file not shown.
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
@ -765,18 +765,35 @@ class BomList(generics.ListCreateAPIView):
|
||||
|
||||
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?
|
||||
part = self.request.query_params.get('part', None)
|
||||
part = params.get('part', None)
|
||||
|
||||
if part is not None:
|
||||
queryset = queryset.filter(part=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:
|
||||
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
|
||||
|
||||
permission_classes = [
|
||||
|
@ -37,4 +37,4 @@ class PartConfig(AppConfig):
|
||||
part.image = None
|
||||
part.save()
|
||||
except (OperationalError, ProgrammingError):
|
||||
print("Could not generate Part thumbnails")
|
||||
pass
|
||||
|
@ -231,7 +231,8 @@ class EditBomItemForm(HelperForm):
|
||||
'quantity',
|
||||
'reference',
|
||||
'overage',
|
||||
'note'
|
||||
'note',
|
||||
'optional',
|
||||
]
|
||||
|
||||
# Prevent editing of the part associated with this BomItem
|
||||
|
18
InvenTree/part/migrations/0051_bomitem_optional.py
Normal file
18
InvenTree/part/migrations/0051_bomitem_optional.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -111,6 +111,58 @@ class PartCategory(InvenTreeTree):
|
||||
""" True if there are any parts in this category """
|
||||
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')
|
||||
def before_delete_part_category(sender, instance, using, **kwargs):
|
||||
@ -382,7 +434,7 @@ class Part(MPTTModel):
|
||||
|
||||
return _('Next available serial numbers are') + ' ' + text
|
||||
else:
|
||||
text = str(latest)
|
||||
text = str(latest + 1)
|
||||
|
||||
return _('Next available serial number is') + ' ' + text
|
||||
|
||||
@ -732,12 +784,13 @@ class Part(MPTTModel):
|
||||
""" 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:
|
||||
quantity = 0
|
||||
query = stock_items.aggregate(
|
||||
quantity=Coalesce(Sum('quantity'), Decimal(0))
|
||||
)
|
||||
|
||||
return quantity
|
||||
return query['quantity']
|
||||
|
||||
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)
|
||||
sub_part: Link to the child part (the part that will be consumed)
|
||||
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)
|
||||
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
|
||||
@ -1480,6 +1534,8 @@ class BomItem(models.Model):
|
||||
# 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'))
|
||||
|
||||
optional = models.BooleanField(default=False, help_text=_("This BOM item is optional"))
|
||||
|
||||
overage = models.CharField(max_length=24, blank=True, validators=[validators.validate_overage],
|
||||
help_text=_('Estimated build wastage quantity (absolute or percentage)')
|
||||
)
|
||||
|
@ -403,6 +403,7 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
||||
'quantity',
|
||||
'reference',
|
||||
'price_range',
|
||||
'optional',
|
||||
'overage',
|
||||
'note',
|
||||
'validated',
|
||||
|
@ -120,8 +120,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% block category_tables %}
|
||||
<table class='table table-striped table-condensed' data-toolbar='#button-toolbar' id='part-table'>
|
||||
</table>
|
||||
{% endblock category_tables %}
|
||||
|
||||
{% endblock %}
|
||||
{% block js_load %}
|
||||
|
31
InvenTree/part/templates/part/category_parametric.html
Normal file
31
InvenTree/part/templates/part/category_parametric.html
Normal 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 %}
|
12
InvenTree/part/templates/part/category_partlist.html
Normal file
12
InvenTree/part/templates/part/category_partlist.html
Normal 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 %}
|
11
InvenTree/part/templates/part/category_tabs.html
Normal file
11
InvenTree/part/templates/part/category_tabs.html
Normal 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>
|
@ -1,7 +1,7 @@
|
||||
from django.test import TestCase
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from .models import Part, PartCategory
|
||||
from .models import Part, PartCategory, PartParameter, PartParameterTemplate
|
||||
|
||||
|
||||
class CategoryTest(TestCase):
|
||||
@ -15,6 +15,7 @@ class CategoryTest(TestCase):
|
||||
'category',
|
||||
'part',
|
||||
'location',
|
||||
'params',
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
@ -94,6 +95,31 @@ class CategoryTest(TestCase):
|
||||
|
||||
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):
|
||||
# Test that an illegal character is prohibited in a category name
|
||||
|
||||
|
@ -77,7 +77,8 @@ part_category_urls = [
|
||||
url(r'^edit/?', views.CategoryEdit.as_view(), name='category-edit'),
|
||||
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 = [
|
||||
|
@ -1872,10 +1872,51 @@ class PartParameterDelete(AjaxDeleteView):
|
||||
|
||||
class CategoryDetail(DetailView):
|
||||
""" Detail view for PartCategory """
|
||||
|
||||
model = PartCategory
|
||||
context_object_name = 'category'
|
||||
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):
|
||||
|
@ -8,6 +8,8 @@ from __future__ import unicode_literals
|
||||
from django import forms
|
||||
from django.forms.utils import ErrorDict
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from mptt.fields import TreeNodeChoiceField
|
||||
|
||||
@ -17,6 +19,8 @@ from InvenTree.fields import RoundingDecimalFormField
|
||||
|
||||
from report.models import TestReport
|
||||
|
||||
from part.models import Part
|
||||
|
||||
from .models import StockLocation, StockItem, StockItemTracking
|
||||
from .models import StockItemAttachment
|
||||
from .models import StockItemTestResult
|
||||
@ -271,6 +275,59 @@ class ExportOptionsForm(HelperForm):
|
||||
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):
|
||||
"""
|
||||
Form for uninstalling a stock item which is installed in another item.
|
||||
|
18
InvenTree/stock/migrations/0052_stockitem_is_building.py
Normal file
18
InvenTree/stock/migrations/0052_stockitem_is_building.py
Normal 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),
|
||||
),
|
||||
]
|
@ -130,6 +130,7 @@ class StockItem(MPTTModel):
|
||||
status: Status of this StockItem (ref: InvenTree.status_codes.StockStatus)
|
||||
notes: Extra notes field
|
||||
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)
|
||||
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)
|
||||
@ -142,6 +143,7 @@ class StockItem(MPTTModel):
|
||||
build_order=None,
|
||||
belongs_to=None,
|
||||
customer=None,
|
||||
is_building=False,
|
||||
status__in=StockStatus.AVAILABLE_CODES
|
||||
)
|
||||
|
||||
@ -273,11 +275,25 @@ class StockItem(MPTTModel):
|
||||
# TODO - Find a test than can be perfomed...
|
||||
pass
|
||||
|
||||
# Ensure that the item cannot be assigned to itself
|
||||
if self.belongs_to and self.belongs_to.pk == self.pk:
|
||||
raise ValidationError({
|
||||
'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):
|
||||
return reverse('stock-item-detail', kwargs={'pk': self.id})
|
||||
|
||||
@ -389,6 +405,10 @@ class StockItem(MPTTModel):
|
||||
related_name='build_outputs',
|
||||
)
|
||||
|
||||
is_building = models.BooleanField(
|
||||
default=False,
|
||||
)
|
||||
|
||||
purchase_order = models.ForeignKey(
|
||||
'order.PurchaseOrder',
|
||||
on_delete=models.SET_NULL,
|
||||
@ -600,12 +620,13 @@ class StockItem(MPTTModel):
|
||||
return self.installedItemCount() > 0
|
||||
|
||||
@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
|
||||
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
|
||||
notes: Any notes associated with the operation
|
||||
"""
|
||||
@ -614,18 +635,29 @@ class StockItem(MPTTModel):
|
||||
if self.belongs_to is not None:
|
||||
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
|
||||
self.belongs_to = otherItem
|
||||
if stock_item is None:
|
||||
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!
|
||||
self.addTransactionNote(
|
||||
_('Installed in stock item') + ' ' + str(otherItem.pk),
|
||||
# Add a transaction note to the other item
|
||||
stock_item.addTransactionNote(
|
||||
_('Installed into stock item') + ' ' + str(self.pk),
|
||||
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
|
||||
@ -645,16 +677,31 @@ class StockItem(MPTTModel):
|
||||
|
||||
# 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.location = location
|
||||
|
||||
self.save()
|
||||
|
||||
if location:
|
||||
url = location.get_absolute_url()
|
||||
else:
|
||||
url = ''
|
||||
|
||||
# Add a transaction note!
|
||||
self.addTransactionNote(
|
||||
_('Uninstalled into location') + ' ' + str(location),
|
||||
user,
|
||||
notes=notes
|
||||
notes=notes,
|
||||
url=url
|
||||
)
|
||||
|
||||
@property
|
||||
@ -688,6 +735,10 @@ class StockItem(MPTTModel):
|
||||
if self.customer is not None:
|
||||
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
|
||||
if self.status in StockStatus.UNAVAILABLE_CODES:
|
||||
return False
|
||||
@ -838,20 +889,20 @@ class StockItem(MPTTModel):
|
||||
|
||||
# Do not split a serialized part
|
||||
if self.serialized:
|
||||
return
|
||||
return self
|
||||
|
||||
try:
|
||||
quantity = Decimal(quantity)
|
||||
except (InvalidOperation, ValueError):
|
||||
return
|
||||
return self
|
||||
|
||||
# Doesn't make sense for a zero quantity
|
||||
if quantity <= 0:
|
||||
return
|
||||
return self
|
||||
|
||||
# Also doesn't make sense to split the full amount
|
||||
if quantity >= self.quantity:
|
||||
return
|
||||
return self
|
||||
|
||||
# Create a new StockItem object, duplicating relevant fields
|
||||
# Nullify the PK so a new record is created
|
||||
|
17
InvenTree/stock/templates/stock/item_install.html
Normal file
17
InvenTree/stock/templates/stock/item_install.html
Normal 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 %}
|
@ -10,19 +10,7 @@
|
||||
<h4>{% trans "Installed Stock Items" %}</h4>
|
||||
<hr>
|
||||
|
||||
<div id='button-toolbar'>
|
||||
<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>
|
||||
<table class='table table-striped table-condensed' id='installed-table'></table>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@ -30,135 +18,14 @@
|
||||
|
||||
{{ block.super }}
|
||||
|
||||
$('#installed-table').inventreeTable({
|
||||
formatNoMatches: function() {
|
||||
return '{% trans "No stock items installed" %}';
|
||||
},
|
||||
url: "{% url 'api-stock-list' %}",
|
||||
queryParams: {
|
||||
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',
|
||||
]
|
||||
});
|
||||
loadInstalledInTable(
|
||||
$('#installed-table'),
|
||||
{
|
||||
stock_item: {{ item.pk }},
|
||||
part: {{ item.part.pk }},
|
||||
quantity: {{ item.quantity }},
|
||||
}
|
||||
);
|
||||
|
||||
$('#multi-item-uninstall').click(function() {
|
||||
|
||||
|
@ -1,6 +1,8 @@
|
||||
{% extends "modal_form.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block pre_form_content %}
|
||||
Create serialized items from this stock item.<br>
|
||||
Select quantity to serialize, and unique serial numbers.
|
||||
{% trans "Create serialized items from this stock item." %}
|
||||
<br>
|
||||
{% trans "Select quantity to serialize, and unique serial numbers." %}
|
||||
{% endblock %}
|
@ -7,7 +7,9 @@ import datetime
|
||||
|
||||
from .models import StockLocation, StockItem, StockItemTracking
|
||||
from .models import StockItemTestResult
|
||||
|
||||
from part.models import Part
|
||||
from build.models import Build
|
||||
|
||||
|
||||
class StockTest(TestCase):
|
||||
@ -47,6 +49,35 @@ class StockTest(TestCase):
|
||||
Part.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):
|
||||
self.assertEqual(StockLocation.objects.count(), 7)
|
||||
|
||||
|
@ -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'^assign/', views.StockItemAssignToCustomer.as_view(), name='stock-item-assign'),
|
||||
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'),
|
||||
|
||||
|
@ -683,6 +683,106 @@ class StockItemQRCode(QRCodeView):
|
||||
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):
|
||||
"""
|
||||
View for uninstalling one or more StockItems,
|
||||
|
@ -39,6 +39,7 @@
|
||||
<link rel="stylesheet" href="{% static 'css/select2.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-table-filter-control.css' %}">
|
||||
<link rel="stylesheet" href="{% static 'css/inventree.css' %}">
|
||||
<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-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-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/moment.js' %}"></script>
|
||||
|
@ -169,6 +169,10 @@ function loadBomTable(table, options) {
|
||||
// Let's make it a bit more pretty
|
||||
text = parseFloat(text);
|
||||
|
||||
if (row.optional) {
|
||||
text += " ({% trans "Optional" %})";
|
||||
}
|
||||
|
||||
if (row.overage) {
|
||||
text += "<small> (+" + row.overage + ") </small>";
|
||||
}
|
||||
|
@ -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={}) {
|
||||
/* Load part listing data into specified table.
|
||||
*
|
||||
|
@ -470,10 +470,16 @@ function loadStockTable(table, options) {
|
||||
|
||||
if (row.customer) {
|
||||
html += `<span class='fas fa-user-tie label-right' title='{% trans "Stock item has been assigned to customer" %}'></span>`;
|
||||
} else if (row.build_order) {
|
||||
html += `<span class='fas fa-tools label-right' title='{% trans "Stock item was assigned to a build 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>`;
|
||||
} else {
|
||||
if (row.build_order) {
|
||||
html += `<span class='fas fa-tools label-right' title='{% trans "Stock item was assigned to a build 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
|
||||
@ -520,6 +526,9 @@ function loadStockTable(table, options) {
|
||||
} else if (row.customer) {
|
||||
var text = "{% trans "Shipped to customer" %}";
|
||||
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) {
|
||||
return renderLink(value, `/stock/location/${row.location}/`);
|
||||
@ -799,3 +808,300 @@ function createNewStockItem(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();
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
@ -15,9 +15,16 @@
|
||||
</div>
|
||||
<div class="navbar-collapse collapse">
|
||||
<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>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
{% if perms.build.view_build %}
|
||||
<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'>
|
||||
<a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='fas fa-shopping-cart icon-header'></span>{% trans "Buy" %}</a>
|
||||
<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>
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.order.view_salesorder %}
|
||||
<li class='nav navbar-nav'>
|
||||
<a class='dropdown-toggle' data-toggle='dropdown' href='#'><span class='fas fa-truck icon-header'></span>{% trans "Sell" %}</a>
|
||||
<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>
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<ul class="nav navbar-nav navbar-right">
|
||||
{% include "search_form.html" %}
|
||||
|
@ -1,3 +1,135 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# from __future__ import unicode_literals
|
||||
# from django.contrib import admin
|
||||
from __future__ import unicode_literals
|
||||
|
||||
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)
|
||||
|
@ -1,8 +1,33 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db.utils import OperationalError, ProgrammingError
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UsersConfig(AppConfig):
|
||||
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)
|
||||
|
31
InvenTree/users/migrations/0001_initial.py
Normal file
31
InvenTree/users/migrations/0001_initial.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
18
InvenTree/users/migrations/0002_auto_20201004_0158.py
Normal file
18
InvenTree/users/migrations/0002_auto_20201004_0158.py
Normal 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),
|
||||
),
|
||||
]
|
0
InvenTree/users/migrations/__init__.py
Normal file
0
InvenTree/users/migrations/__init__.py
Normal file
@ -1 +1,317 @@
|
||||
# -*- 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)
|
||||
|
@ -1,4 +1,157 @@
|
||||
# -*- 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)
|
||||
|
@ -25,5 +25,6 @@ django-stdimage==5.1.1 # Advanced ImageField management
|
||||
django-tex==1.1.7 # LaTeX PDF export
|
||||
django-weasyprint==1.0.1 # HTML PDF export
|
||||
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
|
Loading…
Reference in New Issue
Block a user